asteroid/template/admin.ctml

383 lines
15 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>
</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. Changes take effect within 5-10 seconds.</p>
<div class="queue-controls">
<button id="refresh-queue" class="btn btn-secondary">🔄 Refresh Queue</button>
<button id="load-from-m3u" class="btn btn-success">📂 Load Queue from M3U</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 Tracks</button>
</div>
<div id="stream-queue-container" class="queue-list">
<div class="loading">Loading queue...</div>
</div>
<div class="queue-actions">
<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>
<!-- Player Control -->
<div class="admin-section">
<h2>Player Control</h2>
<div class="card">
<h3>🎵 Player Control</h3>
<div class="player-controls">
<button id="player-play" class="btn btn-primary" onclick="playTrack()">▶️ Play</button>
<button id="player-pause" class="btn btn-secondary" onclick="pausePlayer()">⏸️ Pause</button>
<button id="player-stop" class="btn btn-secondary" onclick="stopPlayer()">⏹️ Stop</button>
<button id="player-resume" class="btn btn-secondary" onclick="resumePlayer()">▶️ Resume</button>
</div>
<div id="player-status" class="status-info">
Status: <span id="player-state">Unknown</span><br>
Current Track: <span id="current-track">None</span>
</div>
</div>
<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>