432 lines
18 KiB
Plaintext
432 lines
18 KiB
Plaintext
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<title data-text="title">Asteroid Radio - Admin Dashboard</title>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||
<script src="/asteroid/static/js/auth-ui.js"></script>
|
||
<script src="/asteroid/static/js/admin.js"></script>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1>🎛️ ADMIN DASHBOARD</h1>
|
||
<div class="nav">
|
||
<a href="/asteroid">Home</a>
|
||
<a href="/asteroid/player">Player</a>
|
||
<a href="/asteroid/profile">Profile</a>
|
||
<a href="/asteroid/admin/users">👥 Users</a>
|
||
<a href="/asteroid/logout" class="btn-logout">Logout</a>
|
||
</div>
|
||
|
||
<!-- System Status -->
|
||
<div class="admin-section">
|
||
<h2>System Status</h2>
|
||
<div class="admin-grid">
|
||
<div class="status-card">
|
||
<h3>Server Status</h3>
|
||
<p class="status-good" data-text="server-status">🟢 Running</p>
|
||
</div>
|
||
<div class="status-card">
|
||
<h3>Database Status</h3>
|
||
<p class="status-good" data-text="database-status">🟢 Connected</p>
|
||
</div>
|
||
<div class="status-card">
|
||
<h3>Liquidsoap Status</h3>
|
||
<p class="status-error" data-text="liquidsoap-status">🔴 Not Running</p>
|
||
</div>
|
||
<div class="status-card">
|
||
<h3>Icecast Status</h3>
|
||
<p class="status-error" data-text="icecast-status">🔴 Not Running</p>
|
||
<button id="icecast-restart" class="btn btn-danger btn-sm" style="margin-top: 8px;">🔄 Restart</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Listener Statistics -->
|
||
<div class="admin-section">
|
||
<h2>📊 Current Listeners</h2>
|
||
<table class="listener-stats-table" style="table-layout: fixed; width: 100%;">
|
||
<colgroup>
|
||
<col style="width: 25%;">
|
||
<col style="width: 25%;">
|
||
<col style="width: 25%;">
|
||
<col style="width: 25%;">
|
||
</colgroup>
|
||
<thead>
|
||
<tr>
|
||
<th>🎵 MP3</th>
|
||
<th>🎧 AAC</th>
|
||
<th>📱 Low</th>
|
||
<th>📈 Total</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td style="text-align: center;"><span class="stat-number" id="listeners-mp3">0</span></td>
|
||
<td style="text-align: center;"><span class="stat-number" id="listeners-aac">0</span></td>
|
||
<td style="text-align: center;"><span class="stat-number" id="listeners-low">0</span></td>
|
||
<td style="text-align: center;"><span class="stat-number" id="listeners-total">0</span></td>
|
||
</tr>
|
||
<tr class="stat-peak-row">
|
||
<td style="text-align: center;">Peak: <span id="peak-mp3">0</span></td>
|
||
<td style="text-align: center;">Peak: <span id="peak-aac">0</span></td>
|
||
<td style="text-align: center;">Peak: <span id="peak-low">0</span></td>
|
||
<td style="text-align: center;">Updated: <span id="stats-updated">--</span></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<div class="admin-controls" style="margin-top: 10px;">
|
||
<button id="refresh-stats" class="btn btn-secondary" onclick="refreshListenerStats()">🔄 Refresh</button>
|
||
<span id="stats-status" style="margin-left: 15px;"></span>
|
||
</div>
|
||
|
||
<!-- Geo Stats -->
|
||
<h3 style="margin-top: 20px;">🌍 Listener Locations (Last 7 Days)</h3>
|
||
<div id="geo-stats-container">
|
||
<table class="listener-stats-table" id="geo-stats-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Country</th>
|
||
<th>Listeners</th>
|
||
<th>Minutes</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="geo-stats-body">
|
||
<tr><td colspan="3" style="color: #888;">Loading...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Music Library Management -->
|
||
<div class="admin-section">
|
||
<h2>Music Library Management</h2>
|
||
|
||
<!-- Music Library Info -->
|
||
<div class="upload-section">
|
||
<h3>Music Library</h3>
|
||
<div class="upload-info">
|
||
<p>The music library is mounted from your local filesystem into the Liquidsoap container.</p>
|
||
<p><strong>To add music:</strong></p>
|
||
<ol>
|
||
<li>Add files to your music library directory (set via <code>MUSIC_LIBRARY</code> env var)</li>
|
||
<li>Click "Scan Library" to index new tracks into the database</li>
|
||
</ol>
|
||
<p><em>Supported formats: MP3, FLAC, OGG, WAV, OPUS</em></p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-controls">
|
||
<button id="scan-library" class="btn btn-primary">🔍 Scan Library</button>
|
||
<button id="refresh-tracks" class="btn btn-secondary">🔄 Refresh Track List</button>
|
||
<span id="scan-status" style="margin-left: 15px; font-weight: bold;"></span>
|
||
</div>
|
||
|
||
<div class="track-stats">
|
||
<p>Total Tracks: <span id="track-count" data-text="track-count">0</span></p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Track Management -->
|
||
<div class="admin-section">
|
||
<h2>Track Management</h2>
|
||
<div class="track-controls">
|
||
<input type="text" id="track-search" placeholder="Search tracks..." class="search-input">
|
||
<select id="sort-tracks" class="sort-select">
|
||
<option value="title">Sort by Title</option>
|
||
<option value="artist">Sort by Artist</option>
|
||
<option value="album">Sort by Album</option>
|
||
</select>
|
||
<select id="tracks-per-page" class="sort-select" onchange="changeTracksPerPage()">
|
||
<option value="10">10 per page</option>
|
||
<option value="20" selected>20 per page</option>
|
||
<option value="50">50 per page</option>
|
||
<option value="100">100 per page</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div id="tracks-container" class="tracks-list">
|
||
<div class="loading">Loading tracks...</div>
|
||
</div>
|
||
|
||
<!-- Pagination Controls -->
|
||
<div id="pagination-controls" style="display: none; margin-top: 20px; text-align: center;">
|
||
<button onclick="goToPage(1)" class="btn btn-secondary">« First</button>
|
||
<button onclick="previousPage()" class="btn btn-secondary">‹ Prev</button>
|
||
<span id="page-info" style="margin: 0 15px; font-weight: bold;">Page 1 of 1</span>
|
||
<button onclick="nextPage()" class="btn btn-secondary">Next ›</button>
|
||
<button onclick="goToLastPage()" class="btn btn-secondary">Last »</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Stream Queue Management -->
|
||
<div class="admin-section">
|
||
<h2>🎵 Stream Queue Management</h2>
|
||
<p>Manage the live stream playback queue. Liquidsoap watches <code>stream-queue.m3u</code> and reloads automatically.</p>
|
||
|
||
<!-- Playlist Selection -->
|
||
<div class="playlist-controls" style="margin-bottom: 20px; padding: 15px; background: #2a2a2a; border-radius: 8px;">
|
||
<h3 style="margin-top: 0;">📋 Load Playlist</h3>
|
||
<div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
|
||
<select id="playlist-select" class="sort-select" style="min-width: 250px;">
|
||
<option value="">-- Select a playlist --</option>
|
||
</select>
|
||
<button id="load-playlist-btn" class="btn btn-success">📂 Load Selected</button>
|
||
<button id="refresh-playlists-btn" class="btn btn-secondary">🔄 Refresh List</button>
|
||
</div>
|
||
<p style="margin: 10px 0 0 0; font-size: 0.9em; color: #888;">
|
||
Loading a playlist will copy it to <code>stream-queue.m3u</code> and Liquidsoap will start playing it.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Queue Controls -->
|
||
<div class="queue-controls" style="margin-bottom: 15px;">
|
||
<button id="refresh-queue" class="btn btn-secondary">🔄 Refresh Queue</button>
|
||
<button id="save-queue-btn" class="btn btn-primary">💾 Save Queue</button>
|
||
<button id="clear-queue-btn" class="btn btn-warning">🗑️ Clear Queue</button>
|
||
<button id="add-random-tracks" class="btn btn-info">🎲 Add 10 Random</button>
|
||
</div>
|
||
|
||
<!-- Save As -->
|
||
<div style="margin-bottom: 15px; display: flex; gap: 10px; align-items: center;">
|
||
<input type="text" id="save-as-name" placeholder="New playlist name..." class="search-input" style="max-width: 250px;">
|
||
<button id="save-as-btn" class="btn btn-success">💾 Save As New Playlist</button>
|
||
</div>
|
||
|
||
<!-- Queue Status -->
|
||
<div id="queue-status" style="margin-bottom: 15px; padding: 10px; background: #1a1a1a; border-radius: 4px;">
|
||
<span id="queue-count">0</span> tracks in queue
|
||
</div>
|
||
|
||
<!-- Queue Contents -->
|
||
<div id="stream-queue-container" class="queue-list" style="max-height: 400px; overflow-y: auto;">
|
||
<div class="loading">Loading queue...</div>
|
||
</div>
|
||
|
||
<div class="queue-actions" style="margin-top: 20px;">
|
||
<h3>Add Tracks to Queue</h3>
|
||
<input type="text" id="queue-track-search" placeholder="Search tracks to add..." class="search-input">
|
||
<div id="queue-track-results" class="track-results"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Liquidsoap Stream Control -->
|
||
<div class="admin-section">
|
||
<h2>📡 Stream Control (Liquidsoap)</h2>
|
||
<p>Control the live audio stream. Commands are sent directly to Liquidsoap.</p>
|
||
|
||
<!-- Status Display -->
|
||
<div id="liquidsoap-status" style="margin-bottom: 20px; padding: 15px; background: #1a1a1a; border-radius: 8px;">
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
|
||
<div>
|
||
<strong>Uptime:</strong> <span id="ls-uptime">--</span>
|
||
</div>
|
||
<div>
|
||
<strong>Remaining:</strong> <span id="ls-remaining">--</span>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top: 10px;">
|
||
<strong>Now Playing:</strong> <span id="ls-metadata">--</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Control Buttons -->
|
||
<div class="queue-controls" style="margin-bottom: 15px;">
|
||
<button id="ls-refresh-status" class="btn btn-secondary">🔄 Refresh Status</button>
|
||
<button id="ls-skip" class="btn btn-warning">⏭️ Skip Track</button>
|
||
<button id="ls-reload" class="btn btn-info">📂 Reload Playlist</button>
|
||
<button id="ls-restart" class="btn btn-danger">🔄 Restart Container</button>
|
||
</div>
|
||
|
||
<p style="font-size: 0.9em; color: #888;">
|
||
<strong>Skip Track:</strong> Immediately skip to the next track in the playlist.<br>
|
||
<strong>Reload Playlist:</strong> Force Liquidsoap to re-read stream-queue.m3u.<br>
|
||
<strong>Restart Container:</strong> Restart the Liquidsoap Docker container (causes brief stream interruption).
|
||
</p>
|
||
</div>
|
||
|
||
<!-- User Management -->
|
||
<div class="admin-section">
|
||
<div class="card">
|
||
<h3>👥 User Management</h3>
|
||
<p>Manage user accounts, roles, and permissions.</p>
|
||
<div class="controls">
|
||
<a href="/asteroid/admin/users" class="btn btn-primary">👥 Manage Users</a>
|
||
</div>
|
||
|
||
<!-- Admin Password Reset Form -->
|
||
<div class="form-section" style="margin-top: 20px;">
|
||
<h4>🔒 Reset User Password</h4>
|
||
<form id="admin-reset-password-form" onsubmit="return resetUserPassword(event)">
|
||
<div class="form-group">
|
||
<label for="reset-username">Username:</label>
|
||
<input type="text" id="reset-username" name="username" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="reset-new-password">New Password:</label>
|
||
<input type="password" id="reset-new-password" name="new-password" required minlength="8">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="reset-confirm-password">Confirm Password:</label>
|
||
<input type="password" id="reset-confirm-password" name="confirm-password" required minlength="8">
|
||
</div>
|
||
<div id="reset-password-message" class="message"></div>
|
||
<button type="submit" class="btn btn-primary">Reset Password</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// Listener Statistics
|
||
function refreshListenerStats() {
|
||
const statusEl = document.getElementById('stats-status');
|
||
statusEl.textContent = 'Loading...';
|
||
|
||
fetch('/api/asteroid/stats/current')
|
||
.then(response => response.json())
|
||
.then(result => {
|
||
const data = result.data || result;
|
||
if (data.status === 'success' && data.listeners) {
|
||
// Process listener data - get most recent for each mount
|
||
const mounts = {};
|
||
data.listeners.forEach(item => {
|
||
// item is [mount, "/asteroid.mp3", listeners, 1, timestamp, 123456]
|
||
const mount = item[1];
|
||
const listeners = item[3];
|
||
if (!mounts[mount] || item[5] > mounts[mount].timestamp) {
|
||
mounts[mount] = { listeners: listeners, timestamp: item[5] };
|
||
}
|
||
});
|
||
|
||
// Update UI
|
||
const mp3 = mounts['/asteroid.mp3']?.listeners || 0;
|
||
const aac = mounts['/asteroid.aac']?.listeners || 0;
|
||
const low = mounts['/asteroid-low.mp3']?.listeners || 0;
|
||
|
||
document.getElementById('listeners-mp3').textContent = mp3;
|
||
document.getElementById('listeners-aac').textContent = aac;
|
||
document.getElementById('listeners-low').textContent = low;
|
||
document.getElementById('listeners-total').textContent = mp3 + aac + low;
|
||
|
||
const now = new Date();
|
||
document.getElementById('stats-updated').textContent =
|
||
now.toLocaleTimeString();
|
||
|
||
statusEl.textContent = '';
|
||
} else {
|
||
statusEl.textContent = 'No data available';
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error fetching stats:', error);
|
||
statusEl.textContent = 'Error loading stats';
|
||
});
|
||
}
|
||
|
||
// Country code to flag emoji
|
||
function countryToFlag(countryCode) {
|
||
if (!countryCode || countryCode.length !== 2) return '🌍';
|
||
const codePoints = countryCode
|
||
.toUpperCase()
|
||
.split('')
|
||
.map(char => 127397 + char.charCodeAt(0));
|
||
return String.fromCodePoint(...codePoints);
|
||
}
|
||
|
||
// Fetch and display geo stats
|
||
function refreshGeoStats() {
|
||
fetch('/api/asteroid/stats/geo?days=7')
|
||
.then(response => response.json())
|
||
.then(result => {
|
||
const data = result.data || result;
|
||
const tbody = document.getElementById('geo-stats-body');
|
||
|
||
if (data.status === 'success' && data.geo && data.geo.length > 0) {
|
||
tbody.innerHTML = data.geo.map(item => {
|
||
const country = item.country_code || item[0];
|
||
const listeners = item.total_listeners || item[1] || 0;
|
||
const minutes = item.total_minutes || item[2] || 0;
|
||
return `<tr>
|
||
<td>${countryToFlag(country)} ${country}</td>
|
||
<td>${listeners}</td>
|
||
<td>${minutes}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
} else {
|
||
tbody.innerHTML = '<tr><td colspan="3" style="color: #888;">No geo data yet</td></tr>';
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error fetching geo stats:', error);
|
||
document.getElementById('geo-stats-body').innerHTML =
|
||
'<tr><td colspan="3" style="color: #ff6666;">Error loading geo data</td></tr>';
|
||
});
|
||
}
|
||
|
||
// Auto-refresh stats every 30 seconds
|
||
setInterval(refreshListenerStats, 30000);
|
||
setInterval(refreshGeoStats, 60000);
|
||
|
||
// Initial load
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
refreshListenerStats();
|
||
refreshGeoStats();
|
||
});
|
||
|
||
// Admin password reset handler
|
||
function resetUserPassword(event) {
|
||
event.preventDefault();
|
||
|
||
const username = document.getElementById('reset-username').value;
|
||
const newPassword = document.getElementById('reset-new-password').value;
|
||
const confirmPassword = document.getElementById('reset-confirm-password').value;
|
||
const messageDiv = document.getElementById('reset-password-message');
|
||
|
||
// Client-side validation
|
||
if (newPassword.length < 8) {
|
||
messageDiv.textContent = 'New password must be at least 8 characters';
|
||
messageDiv.className = 'message error';
|
||
return false;
|
||
}
|
||
|
||
if (newPassword !== confirmPassword) {
|
||
messageDiv.textContent = 'Passwords do not match';
|
||
messageDiv.className = 'message error';
|
||
return false;
|
||
}
|
||
|
||
// Send request to API
|
||
const formData = new FormData();
|
||
formData.append('username', username);
|
||
formData.append('new-password', newPassword);
|
||
|
||
fetch('/api/asteroid/admin/reset-password', {
|
||
method: 'POST',
|
||
body: formData
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.status === 'success' || (data.data && data.data.status === 'success')) {
|
||
messageDiv.textContent = 'Password reset successfully for user: ' + username;
|
||
messageDiv.className = 'message success';
|
||
document.getElementById('admin-reset-password-form').reset();
|
||
} else {
|
||
messageDiv.textContent = data.message || data.data?.message || 'Failed to reset password';
|
||
messageDiv.className = 'message error';
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error resetting password:', error);
|
||
messageDiv.textContent = 'Error resetting password';
|
||
messageDiv.className = 'message error';
|
||
});
|
||
|
||
return false;
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|