asteroid/template/admin.ctml

488 lines
20 KiB
Plaintext
Raw Permalink 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>
<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);
}
// Track expanded countries
const expandedCountries = new Set();
// 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;
const isExpanded = expandedCountries.has(country);
const arrow = isExpanded ? '▼' : '▶';
return `<tr class="country-row" data-country="${country}" style="cursor: pointer;" onclick="toggleCountryCities('${country}')">
<td><span class="expand-arrow">${arrow}</span> ${countryToFlag(country)} ${country}</td>
<td>${listeners}</td>
<td>${minutes}</td>
</tr>
<tr class="city-rows" id="cities-${country}" style="display: ${isExpanded ? 'table-row' : 'none'};">
<td colspan="3" style="padding: 0;"><div class="city-container" id="city-container-${country}"></div></td>
</tr>`;
}).join('');
// Re-fetch cities for expanded countries
expandedCountries.forEach(country => fetchCities(country));
} 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>';
});
}
// Toggle city display for a country
function toggleCountryCities(country) {
const cityRow = document.getElementById(`cities-${country}`);
const countryRow = document.querySelector(`tr[data-country="${country}"]`);
const arrow = countryRow.querySelector('.expand-arrow');
if (expandedCountries.has(country)) {
expandedCountries.delete(country);
cityRow.style.display = 'none';
arrow.textContent = '▶';
} else {
expandedCountries.add(country);
cityRow.style.display = 'table-row';
arrow.textContent = '▼';
fetchCities(country);
}
}
// Fetch cities for a country
function fetchCities(country) {
const container = document.getElementById(`city-container-${country}`);
container.innerHTML = '<div style="padding: 5px 20px; color: #888;">Loading cities...</div>';
fetch(`/api/asteroid/stats/geo/cities?country=${country}&days=7`)
.then(response => response.json())
.then(result => {
const data = result.data || result;
if (data.status === 'success' && data.cities && data.cities.length > 0) {
container.innerHTML = '<table style="width: 100%; margin-left: 20px;">' +
data.cities.map(city => `
<tr style="background: rgba(0,255,0,0.05);">
<td style="padding: 3px 10px;">└ ${city.city}</td>
<td style="padding: 3px 10px;">${city.listeners}</td>
<td style="padding: 3px 10px;">${city.minutes}</td>
</tr>
`).join('') + '</table>';
} else {
container.innerHTML = '<div style="padding: 5px 20px; color: #888;">No city data</div>';
}
})
.catch(error => {
console.error('Error fetching cities:', error);
container.innerHTML = '<div style="padding: 5px 20px; color: #ff6666;">Error loading cities</div>';
});
}
// 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>