1598 lines
67 KiB
HTML
1598 lines
67 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Admin Dashboard - Blossom-BitTorrent Gateway</title>
|
|
<link rel="stylesheet" href="/static/style.css">
|
|
<style>
|
|
.admin-nav {
|
|
background: var(--bg-secondary);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
margin-bottom: 30px;
|
|
display: flex;
|
|
gap: 15px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.admin-nav-btn {
|
|
padding: 10px 20px;
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.admin-nav-btn.active,
|
|
.admin-nav-btn:hover {
|
|
background: var(--accent-primary);
|
|
color: white;
|
|
border-color: var(--accent-primary);
|
|
}
|
|
|
|
.admin-section {
|
|
display: none;
|
|
}
|
|
|
|
.admin-section.active {
|
|
display: block;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.admin-table {
|
|
background: var(--bg-secondary);
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.admin-table table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.admin-table th,
|
|
.admin-table td {
|
|
padding: 12px;
|
|
text-align: left;
|
|
border-bottom: 1px solid var(--border-color);
|
|
word-wrap: break-word;
|
|
word-break: break-word;
|
|
max-width: 200px;
|
|
}
|
|
|
|
.admin-table th:first-child,
|
|
.admin-table td:first-child {
|
|
max-width: 50px;
|
|
}
|
|
|
|
.admin-table .hash-short {
|
|
max-width: 120px;
|
|
font-family: monospace;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.admin-table .owner-cell,
|
|
.admin-table .user-profile-cell {
|
|
max-width: 180px;
|
|
overflow: visible;
|
|
}
|
|
|
|
.admin-table th {
|
|
background: var(--bg-tertiary);
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.admin-table td {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.admin-table tr:hover {
|
|
background: var(--bg-tertiary);
|
|
}
|
|
|
|
.admin-controls {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 20px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.admin-form {
|
|
background: var(--bg-secondary);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
margin-bottom: 5px;
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.form-group input,
|
|
.form-group select,
|
|
.form-group textarea {
|
|
width: 100%;
|
|
padding: 10px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.form-group textarea {
|
|
min-height: 80px;
|
|
resize: vertical;
|
|
}
|
|
|
|
.status-badge {
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.status-badge.banned {
|
|
background: var(--danger);
|
|
color: white;
|
|
}
|
|
|
|
.status-badge.active {
|
|
background: var(--success);
|
|
color: white;
|
|
}
|
|
|
|
.status-badge.pending {
|
|
background: var(--warning);
|
|
color: black;
|
|
}
|
|
|
|
.status-badge.processing {
|
|
background: var(--info);
|
|
color: white;
|
|
}
|
|
|
|
.status-badge.queued {
|
|
background: var(--warning);
|
|
color: black;
|
|
}
|
|
|
|
.action-btn.small {
|
|
padding: 4px 8px;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.no-data {
|
|
color: var(--text-muted);
|
|
font-style: italic;
|
|
text-align: center;
|
|
}
|
|
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<h1 id="admin-site-title">🛡️ Admin Dashboard</h1>
|
|
<nav>
|
|
<a href="/">← Back to Gateway</a>
|
|
<div id="admin-auth-status" class="auth-status">
|
|
<span id="admin-user-info">Not logged in</span>
|
|
<button id="admin-logout-btn" onclick="adminLogout()" style="display: none;">Logout</button>
|
|
</div>
|
|
</nav>
|
|
</header>
|
|
|
|
<main id="admin-content" style="display: none;">
|
|
<div class="admin-nav">
|
|
<button class="admin-nav-btn active" onclick="showAdminSection('overview')">Overview</button>
|
|
<button class="admin-nav-btn" onclick="showAdminSection('transcoding')">Transcoding</button>
|
|
<button class="admin-nav-btn" onclick="showAdminSection('users')">Users</button>
|
|
<button class="admin-nav-btn" onclick="showAdminSection('files')">Files</button>
|
|
<button class="admin-nav-btn" onclick="showAdminSection('reports')">Reports</button>
|
|
<button class="admin-nav-btn" onclick="showAdminSection('cleanup')">Cleanup</button>
|
|
<button class="admin-nav-btn" onclick="showAdminSection('logs')">Audit Log</button>
|
|
</div>
|
|
|
|
<!-- Overview Section -->
|
|
<div id="overview-section" class="admin-section active">
|
|
<h2>System Overview</h2>
|
|
<div class="stats-grid" id="admin-stats">
|
|
<!-- Dynamic content -->
|
|
</div>
|
|
|
|
<div class="admin-table">
|
|
<h3>Recent Uploads (24h)</h3>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>File Name</th>
|
|
<th>Size</th>
|
|
<th>Type</th>
|
|
<th>Owner</th>
|
|
<th>Upload Time</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="recent-uploads-table">
|
|
<!-- Dynamic content -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Transcoding Section -->
|
|
<div id="transcoding-section" class="admin-section">
|
|
<h2>Transcoding Monitor</h2>
|
|
|
|
<div class="admin-controls">
|
|
<button class="action-btn" onclick="refreshTranscodingJobs()">↻ Refresh</button>
|
|
<button class="action-btn" onclick="clearFailedJobs()">🗑️ Clear Failed</button>
|
|
<button class="action-btn" onclick="retryFailedJobs()">🔄 Retry Failed</button>
|
|
<button class="action-btn" onclick="pauseTranscoding()">⏸️ Pause Queue</button>
|
|
</div>
|
|
|
|
<!-- Transcoding Stats -->
|
|
<div class="stats-grid" id="transcoding-stats">
|
|
<div class="modern-card">
|
|
<h4>Queue Status</h4>
|
|
<div class="stat-value" id="queue-length">0</div>
|
|
<div class="stat-label">Jobs in Queue</div>
|
|
</div>
|
|
<div class="modern-card">
|
|
<h4>Processing</h4>
|
|
<div class="stat-value" id="processing-jobs">0</div>
|
|
<div class="stat-label">Active Jobs</div>
|
|
</div>
|
|
<div class="modern-card">
|
|
<h4>Completed Today</h4>
|
|
<div class="stat-value" id="completed-today">0</div>
|
|
<div class="stat-label">Successfully Processed</div>
|
|
</div>
|
|
<div class="modern-card">
|
|
<h4>Failed Jobs</h4>
|
|
<div class="stat-value" id="failed-jobs">0</div>
|
|
<div class="stat-label">Require Attention</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Active Jobs -->
|
|
<div class="admin-table">
|
|
<h3>Active Transcoding Jobs</h3>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Job ID</th>
|
|
<th>File Name</th>
|
|
<th>Status</th>
|
|
<th>Progress</th>
|
|
<th>Quality</th>
|
|
<th>Started</th>
|
|
<th>ETA</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="active-jobs-table">
|
|
<tr>
|
|
<td colspan="8" class="no-data">No active jobs</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Job History -->
|
|
<div class="admin-table">
|
|
<h3>Recent Job History</h3>
|
|
<div class="admin-controls">
|
|
<select id="history-filter" onchange="filterJobHistory()">
|
|
<option value="all">All Jobs</option>
|
|
<option value="completed">Completed</option>
|
|
<option value="failed">Failed</option>
|
|
<option value="today">Today</option>
|
|
</select>
|
|
<button class="action-btn" onclick="exportJobHistory()">📊 Export History</button>
|
|
</div>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>File Hash</th>
|
|
<th>File Name</th>
|
|
<th>Status</th>
|
|
<th>Qualities Generated</th>
|
|
<th>Duration</th>
|
|
<th>Started</th>
|
|
<th>Completed</th>
|
|
<th>Error</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="job-history-table">
|
|
<tr>
|
|
<td colspan="9" class="no-data">Loading job history...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- System Health -->
|
|
<div class="admin-form">
|
|
<h3>System Health</h3>
|
|
<div class="stats-grid">
|
|
<div class="modern-card">
|
|
<h4>FFmpeg Status</h4>
|
|
<div class="stat-value" id="ffmpeg-status">Checking...</div>
|
|
<div class="stat-label">Media Processing Engine</div>
|
|
</div>
|
|
<div class="modern-card">
|
|
<h4>Storage Space</h4>
|
|
<div class="stat-value" id="transcode-storage">0 GB</div>
|
|
<div class="stat-label">Used for Transcoded Files</div>
|
|
</div>
|
|
<div class="modern-card">
|
|
<h4>Average Processing Time</h4>
|
|
<div class="stat-value" id="avg-processing-time">-- min</div>
|
|
<div class="stat-label">Per Video File</div>
|
|
</div>
|
|
<div class="modern-card">
|
|
<h4>Success Rate</h4>
|
|
<div class="stat-value" id="success-rate">--%</div>
|
|
<div class="stat-label">Last 30 Days</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Users Section -->
|
|
<div id="users-section" class="admin-section">
|
|
<h2>User Management</h2>
|
|
|
|
<div class="admin-controls">
|
|
<button class="action-btn" onclick="refreshUsers()">↻ Refresh</button>
|
|
<button class="action-btn" onclick="exportUsers()">📥 Export</button>
|
|
</div>
|
|
|
|
<div class="admin-table">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Public Key</th>
|
|
<th>Display Name</th>
|
|
<th>Files</th>
|
|
<th>Storage</th>
|
|
<th>Last Login</th>
|
|
<th>Status</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="users-table">
|
|
<!-- Dynamic content -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Files Section -->
|
|
<div id="files-section" class="admin-section">
|
|
<h2>File Management</h2>
|
|
|
|
<div class="admin-controls">
|
|
<select id="file-storage-filter">
|
|
<option value="">All Storage Types</option>
|
|
<option value="blob">Blobs</option>
|
|
<option value="torrent">Torrents</option>
|
|
</select>
|
|
<select id="file-access-filter">
|
|
<option value="">All Access Levels</option>
|
|
<option value="public">Public</option>
|
|
<option value="private">Private</option>
|
|
</select>
|
|
<button class="action-btn" onclick="refreshFiles()">↻ Refresh</button>
|
|
<button class="action-btn danger" onclick="bulkDeleteFiles()">🗑 Bulk Delete</button>
|
|
</div>
|
|
|
|
<div class="admin-table">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th><input type="checkbox" id="select-all-files" onchange="toggleSelectAll()"></th>
|
|
<th>Name</th>
|
|
<th>Hash</th>
|
|
<th>Size</th>
|
|
<th>Type</th>
|
|
<th>Access</th>
|
|
<th>Owner</th>
|
|
<th>Reports</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="files-table">
|
|
<!-- Dynamic content -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Reports Section -->
|
|
<div id="reports-section" class="admin-section">
|
|
<h2>Content Reports</h2>
|
|
|
|
<div class="admin-controls">
|
|
<select id="report-status-filter">
|
|
<option value="">All Reports</option>
|
|
<option value="pending">Pending</option>
|
|
<option value="resolved">Resolved</option>
|
|
<option value="dismissed">Dismissed</option>
|
|
</select>
|
|
<button class="action-btn" onclick="refreshReports()">↻ Refresh</button>
|
|
</div>
|
|
|
|
<div class="admin-table">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>File</th>
|
|
<th>Reporter</th>
|
|
<th>Reason</th>
|
|
<th>Status</th>
|
|
<th>Date</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="reports-table">
|
|
<!-- Dynamic content -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Cleanup Section -->
|
|
<div id="cleanup-section" class="admin-section">
|
|
<h2>System Cleanup</h2>
|
|
|
|
<div class="admin-form">
|
|
<h3>Cleanup Operations</h3>
|
|
|
|
<div class="form-group">
|
|
<label>Operation Type:</label>
|
|
<select id="cleanup-operation">
|
|
<option value="old_files">Remove Old Files</option>
|
|
<option value="orphaned_chunks">Clean Orphaned Chunks</option>
|
|
<option value="inactive_users">Remove Inactive Users</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Max Age / Days:</label>
|
|
<input type="text" id="cleanup-age" placeholder="e.g., 90d or 365 (days)">
|
|
</div>
|
|
|
|
<button class="action-btn danger" onclick="executeCleanup()">🧹 Execute Cleanup</button>
|
|
</div>
|
|
|
|
<div id="cleanup-results" class="admin-table" style="display: none;">
|
|
<h3>Cleanup Results</h3>
|
|
<div id="cleanup-output"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Logs Section -->
|
|
<div id="logs-section" class="admin-section">
|
|
<h2>Admin Action Log</h2>
|
|
|
|
<div class="admin-controls">
|
|
<button class="action-btn" onclick="refreshLogs()">↻ Refresh</button>
|
|
<button class="action-btn" onclick="exportLogs()">📥 Export</button>
|
|
</div>
|
|
|
|
<div class="admin-table">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Admin</th>
|
|
<th>Action</th>
|
|
<th>Target</th>
|
|
<th>Reason</th>
|
|
<th>Timestamp</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="logs-table">
|
|
<!-- Dynamic content -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Admin Login Form -->
|
|
<div id="admin-login" class="modal" style="position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); display: flex; justify-content: center; align-items: center;">
|
|
<div class="admin-form" style="max-width: 500px; width: 90%;">
|
|
<h2>Admin Access Required</h2>
|
|
<p>Please authenticate with your admin Nostr key to access the admin dashboard.</p>
|
|
|
|
<button id="admin-nip07-login" class="login-btn">
|
|
Login with Browser Extension (NIP-07)
|
|
</button>
|
|
|
|
<div id="admin-login-status" class="status" style="display: none;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="toast-container" class="toast-container"></div>
|
|
|
|
<!-- Ban User Modal -->
|
|
<div id="ban-modal" class="modal" style="display: none;">
|
|
<div class="modal-content">
|
|
<span class="close" onclick="hideBanModal()">×</span>
|
|
<h2>Ban User</h2>
|
|
<div class="form-group">
|
|
<label>User Public Key:</label>
|
|
<input type="text" id="ban-user-pubkey" readonly>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Reason for Ban:</label>
|
|
<textarea id="ban-reason" placeholder="Enter reason for banning this user..."></textarea>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="action-btn danger" onclick="confirmBanUser()">Ban User</button>
|
|
<button class="action-btn" onclick="hideBanModal()">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="/static/nostr-auth.js"></script>
|
|
<script>
|
|
let adminUser = null;
|
|
let currentAdminSection = 'overview';
|
|
let transcodingPollInterval = null;
|
|
|
|
// Admin authentication
|
|
async function checkAdminAuth() {
|
|
if (!window.nostrAuth || !window.nostrAuth.isAuthenticated()) {
|
|
showAdminLogin();
|
|
return false;
|
|
}
|
|
|
|
// Check if user is admin by trying to access admin stats
|
|
try {
|
|
const response = await fetch('/api/admin/stats', {
|
|
credentials: 'include',
|
|
headers: {
|
|
'Authorization': `Bearer ${window.nostrAuth.sessionToken}`
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
adminUser = window.nostrAuth.getCurrentUser();
|
|
showAdminDashboard();
|
|
return true;
|
|
} else {
|
|
showAdminLogin();
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
showAdminLogin();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Admin fetch helper function with authentication
|
|
async function adminFetch(url, options = {}) {
|
|
return fetch(url, {
|
|
credentials: 'include',
|
|
headers: {
|
|
'Authorization': `Bearer ${window.nostrAuth.sessionToken}`,
|
|
'Content-Type': 'application/json',
|
|
...options.headers
|
|
},
|
|
...options
|
|
});
|
|
}
|
|
|
|
function showAdminLogin() {
|
|
document.getElementById('admin-login').style.display = 'block';
|
|
document.getElementById('admin-content').style.display = 'none';
|
|
}
|
|
|
|
async function showAdminDashboard() {
|
|
document.getElementById('admin-login').style.display = 'none';
|
|
document.getElementById('admin-content').style.display = 'block';
|
|
document.getElementById('admin-logout-btn').style.display = 'block';
|
|
|
|
// Set initial fallback display
|
|
document.getElementById('admin-user-info').textContent = `Admin: ${adminUser.substring(0, 8)}...`;
|
|
|
|
// Fetch admin profile information
|
|
try {
|
|
const response = await fetch(`/api/profile/${adminUser}`);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data.success && data.profile) {
|
|
const profile = data.profile;
|
|
const displayName = profile.display_name || profile.name || (adminUser.substring(0, 8) + '...');
|
|
|
|
if (profile.picture) {
|
|
document.getElementById('admin-user-info').innerHTML = `
|
|
<img src="${profile.picture}" style="width: 24px; height: 24px; border-radius: 50%; margin-right: 8px; vertical-align: middle;">
|
|
Admin: ${displayName}
|
|
`;
|
|
} else {
|
|
document.getElementById('admin-user-info').textContent = `Admin: ${displayName}`;
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.log('Could not fetch admin profile, using fallback');
|
|
}
|
|
|
|
loadAdminStats();
|
|
}
|
|
|
|
async function adminLogout() {
|
|
// Stop any polling intervals
|
|
if (transcodingPollInterval) {
|
|
clearInterval(transcodingPollInterval);
|
|
transcodingPollInterval = null;
|
|
}
|
|
|
|
if (window.nostrAuth) {
|
|
await window.nostrAuth.logout();
|
|
adminUser = null;
|
|
showAdminLogin();
|
|
}
|
|
}
|
|
|
|
// Section navigation
|
|
function showAdminSection(section) {
|
|
currentAdminSection = section;
|
|
|
|
// Update navigation
|
|
document.querySelectorAll('.admin-nav-btn').forEach(btn => btn.classList.remove('active'));
|
|
event.target.classList.add('active');
|
|
|
|
// Show section
|
|
document.querySelectorAll('.admin-section').forEach(sec => sec.classList.remove('active'));
|
|
document.getElementById(section + '-section').classList.add('active');
|
|
|
|
// Clear existing polling intervals
|
|
if (transcodingPollInterval) {
|
|
clearInterval(transcodingPollInterval);
|
|
transcodingPollInterval = null;
|
|
}
|
|
|
|
// Load section data
|
|
switch (section) {
|
|
case 'overview': loadAdminStats(); break;
|
|
case 'transcoding':
|
|
loadTranscodingStats();
|
|
loadTranscodingJobs();
|
|
// Start polling for active jobs
|
|
startTranscodingPolling();
|
|
break;
|
|
case 'users': loadUsers(); break;
|
|
case 'files': loadFiles(); break;
|
|
case 'reports': loadReports(); break;
|
|
case 'logs': loadLogs(); break;
|
|
}
|
|
}
|
|
|
|
// Admin data loading functions
|
|
async function loadAdminStats() {
|
|
// Load stats cards
|
|
try {
|
|
const response = await makeAdminRequest('/api/admin/stats');
|
|
const data = await response.json();
|
|
|
|
if (!response.ok || data.success === false) {
|
|
showToast('Failed to load admin stats: ' + (data.error || 'Unknown error'), 'error');
|
|
} else {
|
|
const stats = data;
|
|
const statsGrid = document.getElementById('admin-stats');
|
|
statsGrid.innerHTML = `
|
|
<div class="stat-card">
|
|
<div class="stat-number">${stats.total_files}</div>
|
|
<div class="stat-label">Total Files</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number">${stats.total_users}</div>
|
|
<div class="stat-label">Total Users</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number">${formatBytes(stats.total_storage)}</div>
|
|
<div class="stat-label">Total Storage</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number">${stats.banned_users}</div>
|
|
<div class="stat-label">Banned Users</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number">${stats.pending_reports}</div>
|
|
<div class="stat-label">Pending Reports</div>
|
|
</div>
|
|
`;
|
|
}
|
|
} catch (error) {
|
|
showToast('Failed to load admin stats: ' + error.message, 'error');
|
|
}
|
|
|
|
// Load recent uploads table
|
|
try {
|
|
const response = await makeAdminRequest('/api/admin/files?limit=20');
|
|
const data = await response.json();
|
|
|
|
if (!response.ok || data.success === false) {
|
|
showToast('Failed to load recent uploads: ' + (data.error || 'Unknown error'), 'error');
|
|
return;
|
|
}
|
|
|
|
const files = Array.isArray(data) ? data : [];
|
|
const tbody = document.getElementById('recent-uploads-table');
|
|
tbody.innerHTML = files.map(file => {
|
|
const ownerName = file.owner_profile?.display_name || file.owner_profile?.name ||
|
|
(file.owner_pubkey ? file.owner_pubkey.substring(0, 8) + '...' : 'System');
|
|
const ownerPic = file.owner_profile?.picture ?
|
|
`<img src="${file.owner_profile.picture}" style="width: 20px; height: 20px; border-radius: 50%; margin-right: 6px;">` : '';
|
|
|
|
return `
|
|
<tr>
|
|
<td>${file.name}</td>
|
|
<td>${formatBytes(file.size)}</td>
|
|
<td>${file.storage_type}</td>
|
|
<td>${ownerPic}${ownerName}</td>
|
|
<td>${new Date(file.created_at).toLocaleDateString()}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
} catch (error) {
|
|
showToast('Failed to load recent uploads: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function loadUsers() {
|
|
const tbody = document.getElementById('users-table');
|
|
|
|
try {
|
|
const response = await makeAdminRequest('/api/admin/users');
|
|
const data = await response.json();
|
|
|
|
if (!response.ok || data.success === false) {
|
|
showToast('Failed to load users: ' + (data.error || 'Unknown error'), 'error');
|
|
return;
|
|
}
|
|
|
|
const users = Array.isArray(data) ? data : [];
|
|
|
|
// Render immediately with fallback display
|
|
tbody.innerHTML = users.map(user => {
|
|
const displayName = user.display_name || (user.pubkey.substring(0, 8) + '...');
|
|
|
|
return `
|
|
<tr data-user-pubkey="${user.pubkey}">
|
|
<td class="hash-short">${user.pubkey.substring(0, 16)}...</td>
|
|
<td class="user-profile-cell" data-pubkey="${user.pubkey}">${displayName}</td>
|
|
<td>${user.file_count}</td>
|
|
<td>${formatBytes(user.storage_used)}</td>
|
|
<td>${new Date(user.last_login).toLocaleDateString()}</td>
|
|
<td>
|
|
<span class="status-badge ${user.is_banned ? 'banned' : 'active'}">
|
|
${user.is_banned ? 'Banned' : 'Active'}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
${user.is_banned
|
|
? `<button class="action-btn" onclick="unbanUser('${user.pubkey}')">Unban</button>`
|
|
: `<button class="action-btn danger" onclick="banUser('${user.pubkey}')">Ban</button>`
|
|
}
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
// Fetch profiles in background
|
|
const pubkeys = users.map(user => user.pubkey);
|
|
if (pubkeys.length > 0) {
|
|
fetchUsersProfiles(pubkeys);
|
|
}
|
|
|
|
} catch (error) {
|
|
showToast('Failed to load users: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function fetchUsersProfiles(pubkeys, retryCount = 0) {
|
|
const maxRetries = 2;
|
|
const timeout = 4000;
|
|
|
|
if (pubkeys.length === 0) return;
|
|
|
|
try {
|
|
console.log(`Fetching user profiles (attempt ${retryCount + 1}/${maxRetries})...`);
|
|
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
|
|
const promises = pubkeys.slice(0, 10).map(async pubkey => {
|
|
try {
|
|
const response = await fetch(`/api/profile/${pubkey}`, {
|
|
signal: controller.signal
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data.success && data.profile) {
|
|
return { pubkey, profile: data.profile };
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Individual profile fetch failed, skip it
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const results = await Promise.allSettled(promises);
|
|
clearTimeout(timeoutId);
|
|
|
|
// Update UI with successful profile fetches
|
|
results.forEach(result => {
|
|
if (result.status === 'fulfilled' && result.value) {
|
|
const { pubkey, profile } = result.value;
|
|
updateUserProfile(pubkey, profile);
|
|
}
|
|
});
|
|
|
|
// Process remaining pubkeys if we had to limit batch size
|
|
if (pubkeys.length > 10) {
|
|
setTimeout(() => {
|
|
fetchUsersProfiles(pubkeys.slice(10), retryCount);
|
|
}, 500);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.log(`User profile fetch attempt ${retryCount + 1} failed:`, error.message);
|
|
|
|
if (retryCount < maxRetries - 1) {
|
|
const delay = Math.pow(2, retryCount) * 1000;
|
|
setTimeout(() => {
|
|
fetchUsersProfiles(pubkeys, retryCount + 1);
|
|
}, delay);
|
|
} else {
|
|
console.log('User profile fetching failed after all retries');
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateUserProfile(pubkey, profile) {
|
|
const profileCells = document.querySelectorAll(`[data-pubkey="${pubkey}"].user-profile-cell`);
|
|
const displayName = profile.display_name || profile.name || (pubkey.substring(0, 8) + '...');
|
|
const profilePic = profile.picture ?
|
|
`<img src="${profile.picture}" style="width: 24px; height: 24px; border-radius: 50%; margin-right: 8px;">` : '';
|
|
|
|
profileCells.forEach(cell => {
|
|
cell.innerHTML = `${profilePic}${displayName}`;
|
|
});
|
|
}
|
|
|
|
async function loadFiles() {
|
|
const tbody = document.getElementById('files-table');
|
|
|
|
try {
|
|
const storageFilter = document.getElementById('file-storage-filter').value;
|
|
const accessFilter = document.getElementById('file-access-filter').value;
|
|
|
|
let url = '/api/admin/files?limit=50';
|
|
if (storageFilter) url += `&storage_type=${storageFilter}`;
|
|
if (accessFilter) url += `&access_level=${accessFilter}`;
|
|
|
|
const response = await makeAdminRequest(url);
|
|
const data = await response.json();
|
|
|
|
if (!response.ok || data.success === false) {
|
|
showToast('Failed to load files: ' + (data.error || 'Unknown error'), 'error');
|
|
return;
|
|
}
|
|
|
|
const files = Array.isArray(data) ? data : [];
|
|
|
|
// Render immediately with fallback display
|
|
tbody.innerHTML = files.map(file => {
|
|
const ownerName = file.owner_pubkey ? file.owner_pubkey.substring(0, 8) + '...' : 'System';
|
|
|
|
return `
|
|
<tr data-file-hash="${file.hash}">
|
|
<td><input type="checkbox" class="file-select" value="${file.hash}"></td>
|
|
<td>${file.name}</td>
|
|
<td class="hash-short">${file.hash.substring(0, 12)}...</td>
|
|
<td>${formatBytes(file.size)}</td>
|
|
<td>${file.storage_type}</td>
|
|
<td>
|
|
<span class="status-badge ${file.access_level === 'private' ? 'banned' : 'active'}">
|
|
${file.access_level}
|
|
</span>
|
|
</td>
|
|
<td class="owner-cell" data-pubkey="${file.owner_pubkey || ''}">${ownerName}</td>
|
|
<td>${file.report_count > 0 ? `<span class="status-badge pending">${file.report_count}</span>` : '0'}</td>
|
|
<td>
|
|
<button class="action-btn danger" onclick="deleteFile('${file.hash}', '${file.name}')">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
// Fetch profiles in background for files with owners
|
|
const ownerPubkeys = files.filter(file => file.owner_pubkey).map(file => file.owner_pubkey);
|
|
if (ownerPubkeys.length > 0) {
|
|
fetchFileOwnersProfiles(ownerPubkeys);
|
|
}
|
|
|
|
} catch (error) {
|
|
showToast('Failed to load files: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function fetchFileOwnersProfiles(pubkeys, retryCount = 0) {
|
|
const maxRetries = 2;
|
|
const timeout = 4000;
|
|
|
|
if (pubkeys.length === 0) return;
|
|
|
|
try {
|
|
console.log(`Fetching file owner profiles (attempt ${retryCount + 1}/${maxRetries})...`);
|
|
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
|
|
const promises = pubkeys.slice(0, 10).map(async pubkey => {
|
|
try {
|
|
const response = await fetch(`/api/profile/${pubkey}`, {
|
|
signal: controller.signal
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data.success && data.profile) {
|
|
return { pubkey, profile: data.profile };
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Individual profile fetch failed, skip it
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const results = await Promise.allSettled(promises);
|
|
clearTimeout(timeoutId);
|
|
|
|
// Update UI with successful profile fetches
|
|
results.forEach(result => {
|
|
if (result.status === 'fulfilled' && result.value) {
|
|
const { pubkey, profile } = result.value;
|
|
updateFileOwnerProfile(pubkey, profile);
|
|
}
|
|
});
|
|
|
|
// Process remaining pubkeys if we had to limit batch size
|
|
if (pubkeys.length > 10) {
|
|
setTimeout(() => {
|
|
fetchFileOwnersProfiles(pubkeys.slice(10), retryCount);
|
|
}, 500);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.log(`File owner profile fetch attempt ${retryCount + 1} failed:`, error.message);
|
|
|
|
if (retryCount < maxRetries - 1) {
|
|
const delay = Math.pow(2, retryCount) * 1000;
|
|
setTimeout(() => {
|
|
fetchFileOwnersProfiles(pubkeys, retryCount + 1);
|
|
}, delay);
|
|
} else {
|
|
console.log('File owner profile fetching failed after all retries');
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateFileOwnerProfile(pubkey, profile) {
|
|
const ownerCells = document.querySelectorAll(`[data-pubkey="${pubkey}"]`);
|
|
const displayName = profile.display_name || profile.name || (pubkey.substring(0, 8) + '...');
|
|
const ownerPic = profile.picture ?
|
|
`<img src="${profile.picture}" style="width: 20px; height: 20px; border-radius: 50%; margin-right: 6px;">` : '';
|
|
|
|
ownerCells.forEach(cell => {
|
|
cell.innerHTML = `${ownerPic}${displayName}`;
|
|
});
|
|
}
|
|
|
|
async function loadReports() {
|
|
try {
|
|
const statusFilter = document.getElementById('report-status-filter').value;
|
|
let url = '/api/admin/reports?limit=50';
|
|
if (statusFilter) url += `&status=${statusFilter}`;
|
|
|
|
const response = await makeAdminRequest(url);
|
|
const data = await response.json();
|
|
|
|
if (!response.ok || data.success === false) {
|
|
showToast('Failed to load reports: ' + (data.error || 'Unknown error'), 'error');
|
|
return;
|
|
}
|
|
|
|
const reports = Array.isArray(data) ? data : [];
|
|
const tbody = document.getElementById('reports-table');
|
|
tbody.innerHTML = reports.map(report => `
|
|
<tr>
|
|
<td>${report.id}</td>
|
|
<td>${report.file_name || 'Unknown'}</td>
|
|
<td class="hash-short">${report.reporter_pubkey.substring(0, 8)}...</td>
|
|
<td>${report.reason}</td>
|
|
<td>
|
|
<span class="status-badge ${report.status}">
|
|
${report.status}
|
|
</span>
|
|
</td>
|
|
<td>${new Date(report.created_at).toLocaleDateString()}</td>
|
|
<td>
|
|
<button class="action-btn danger" onclick="deleteReportedFile('${report.file_hash}')">Delete File</button>
|
|
<button class="action-btn" onclick="dismissReport(${report.id})">Dismiss</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
} catch (error) {
|
|
showToast('Failed to load reports: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function loadLogs() {
|
|
try {
|
|
const response = await makeAdminRequest('/api/admin/logs?limit=100');
|
|
const logs = await response.json();
|
|
|
|
const tbody = document.getElementById('logs-table');
|
|
tbody.innerHTML = logs.map(log => `
|
|
<tr>
|
|
<td>${log.id}</td>
|
|
<td class="hash-short">${log.admin_pubkey.substring(0, 8)}...</td>
|
|
<td>${log.action_type}</td>
|
|
<td class="hash-short">${log.target_id ? log.target_id.substring(0, 12) + '...' : '-'}</td>
|
|
<td>${log.reason || '-'}</td>
|
|
<td>${new Date(log.timestamp).toLocaleString()}</td>
|
|
</tr>
|
|
`).join('');
|
|
} catch (error) {
|
|
showToast('Failed to load logs: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Admin actions
|
|
function banUser(pubkey) {
|
|
document.getElementById('ban-user-pubkey').value = pubkey;
|
|
document.getElementById('ban-modal').style.display = 'block';
|
|
}
|
|
|
|
function hideBanModal() {
|
|
document.getElementById('ban-modal').style.display = 'none';
|
|
document.getElementById('ban-reason').value = '';
|
|
}
|
|
|
|
async function confirmBanUser() {
|
|
const pubkey = document.getElementById('ban-user-pubkey').value;
|
|
const reason = document.getElementById('ban-reason').value;
|
|
|
|
if (!reason.trim()) {
|
|
showToast('Please provide a reason for the ban', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await makeAdminRequest(`/api/admin/users/${pubkey}/ban`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason: reason })
|
|
});
|
|
|
|
if (response.ok) {
|
|
showToast('User banned successfully', 'success');
|
|
hideBanModal();
|
|
loadUsers();
|
|
} else {
|
|
const error = await response.text();
|
|
showToast('Failed to ban user: ' + error, 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Error banning user: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function unbanUser(pubkey) {
|
|
const reason = prompt('Reason for unbanning (optional):');
|
|
|
|
try {
|
|
const response = await makeAdminRequest(`/api/admin/users/${pubkey}/unban`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason: reason || 'Admin unban' })
|
|
});
|
|
|
|
if (response.ok) {
|
|
showToast('User unbanned successfully', 'success');
|
|
loadUsers();
|
|
} else {
|
|
const error = await response.text();
|
|
showToast('Failed to unban user: ' + error, 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Error unbanning user: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function deleteFile(hash, name) {
|
|
const reason = prompt(`Reason for deleting "${name}":`);
|
|
if (!reason) return;
|
|
|
|
try {
|
|
const response = await makeAdminRequest(`/api/admin/files/${hash}`, {
|
|
method: 'DELETE',
|
|
body: JSON.stringify({ reason: reason })
|
|
});
|
|
|
|
if (response.ok) {
|
|
showToast('File deleted successfully', 'success');
|
|
loadFiles();
|
|
} else {
|
|
const error = await response.text();
|
|
showToast('Failed to delete file: ' + error, 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Error deleting file: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function executeCleanup() {
|
|
const operation = document.getElementById('cleanup-operation').value;
|
|
const maxAge = document.getElementById('cleanup-age').value;
|
|
|
|
if (!confirm(`Are you sure you want to execute cleanup operation: ${operation}?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await makeAdminRequest('/api/admin/cleanup', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
operation: operation,
|
|
max_age: maxAge
|
|
})
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
const resultsDiv = document.getElementById('cleanup-results');
|
|
const outputDiv = document.getElementById('cleanup-output');
|
|
|
|
outputDiv.innerHTML = `
|
|
<p><strong>Operation:</strong> ${result.operation}</p>
|
|
<p><strong>Items Deleted:</strong> ${result.result.deleted_count}</p>
|
|
<pre>${JSON.stringify(result.result, null, 2)}</pre>
|
|
`;
|
|
|
|
resultsDiv.style.display = 'block';
|
|
showToast('Cleanup completed successfully', 'success');
|
|
} else {
|
|
showToast('Cleanup failed: ' + result.message, 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Error executing cleanup: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Utility functions
|
|
async function makeAdminRequest(url, options = {}) {
|
|
const defaultOptions = {
|
|
credentials: 'include',
|
|
headers: {
|
|
'Authorization': `Bearer ${window.nostrAuth.sessionToken}`,
|
|
'Content-Type': 'application/json',
|
|
...options.headers
|
|
}
|
|
};
|
|
|
|
return fetch(url, { ...defaultOptions, ...options });
|
|
}
|
|
|
|
function refreshUsers() { loadUsers(); }
|
|
function refreshFiles() { loadFiles(); }
|
|
function refreshReports() { loadReports(); }
|
|
function refreshLogs() { loadLogs(); }
|
|
function refreshTranscodingJobs() { loadTranscodingStats(); loadTranscodingJobs(); }
|
|
|
|
function startTranscodingPolling() {
|
|
// Poll every 5 seconds for active jobs
|
|
transcodingPollInterval = setInterval(() => {
|
|
if (currentAdminSection === 'transcoding') {
|
|
loadTranscodingStats();
|
|
loadTranscodingJobs();
|
|
} else {
|
|
// Stop polling if we're not on the transcoding section
|
|
clearInterval(transcodingPollInterval);
|
|
transcodingPollInterval = null;
|
|
}
|
|
}, 5000);
|
|
}
|
|
|
|
// Transcoding Management Functions
|
|
async function loadTranscodingStats() {
|
|
try {
|
|
const response = await adminFetch('/api/admin/transcoding/stats');
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 503) {
|
|
// Transcoding not enabled
|
|
document.getElementById('queue-length').textContent = 'N/A';
|
|
document.getElementById('processing-jobs').textContent = 'N/A';
|
|
document.getElementById('completed-today').textContent = 'N/A';
|
|
document.getElementById('failed-jobs').textContent = 'N/A';
|
|
document.getElementById('ffmpeg-status').textContent = 'Disabled';
|
|
document.getElementById('transcode-storage').textContent = 'N/A';
|
|
document.getElementById('avg-processing-time').textContent = 'N/A';
|
|
document.getElementById('success-rate').textContent = 'N/A';
|
|
|
|
// Clear active jobs table
|
|
const tbody = document.getElementById('active-jobs-table');
|
|
tbody.innerHTML = '<tr><td colspan="8" class="no-data">Transcoding service disabled</td></tr>';
|
|
return;
|
|
}
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Update stats cards
|
|
document.getElementById('queue-length').textContent = data.stats?.queue_length || 0;
|
|
document.getElementById('processing-jobs').textContent = data.stats?.processing_jobs || 0;
|
|
document.getElementById('completed-today').textContent = data.stats?.completed_today || 0;
|
|
document.getElementById('failed-jobs').textContent = data.stats?.failed_jobs || 0;
|
|
document.getElementById('ffmpeg-status').textContent = data.stats?.ffmpeg_status || 'Unknown';
|
|
document.getElementById('transcode-storage').textContent = data.stats?.transcoded_storage || '0 GB';
|
|
document.getElementById('avg-processing-time').textContent = data.stats?.avg_processing_time || '-- min';
|
|
document.getElementById('success-rate').textContent = data.stats?.success_rate ?
|
|
`${data.stats.success_rate.toFixed(1)}%` : '--%';
|
|
|
|
// Update active jobs table
|
|
updateActiveJobsTable(data.active_jobs);
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load transcoding stats:', error);
|
|
// Only show toast on first error, not during polling
|
|
if (!transcodingPollInterval) {
|
|
showToast('Failed to load transcoding stats: ' + error.message, 'error');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadTranscodingJobs() {
|
|
try {
|
|
const filter = document.getElementById('history-filter')?.value || 'all';
|
|
const response = await adminFetch(`/api/admin/transcoding/jobs?filter=${filter}`);
|
|
const jobs = await response.json();
|
|
|
|
updateJobHistoryTable(jobs);
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load transcoding jobs:', error);
|
|
showToast('Failed to load job history', 'error');
|
|
}
|
|
}
|
|
|
|
function updateActiveJobsTable(jobsData) {
|
|
const tbody = document.getElementById('active-jobs-table');
|
|
if (!jobsData || !jobsData.enabled || !jobsData.jobs || Object.keys(jobsData.jobs).length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="8" class="no-data">No active jobs</td></tr>';
|
|
return;
|
|
}
|
|
|
|
const jobs = jobsData.jobs;
|
|
let html = '';
|
|
|
|
for (const [jobId, job] of Object.entries(jobs)) {
|
|
if (job.Status === 'processing' || job.Status === 'queued') {
|
|
const startTime = job.CreatedAt ? new Date(job.CreatedAt).toLocaleTimeString() : 'Unknown';
|
|
const progress = job.Progress ? `${Math.round(job.Progress)}%` : '0%';
|
|
const eta = estimateETA(job.Progress, job.CreatedAt);
|
|
|
|
html += `
|
|
<tr>
|
|
<td>${jobId.substring(0, 12)}...</td>
|
|
<td>${job.FileHash?.substring(0, 8)}...</td>
|
|
<td><span class="status-badge ${job.Status}">${job.Status}</span></td>
|
|
<td>
|
|
<div class="progress-bar" style="width: 100px; height: 8px; background: var(--bg-tertiary); border-radius: 4px;">
|
|
<div style="width: ${job.Progress || 0}%; height: 100%; background: var(--success); border-radius: 4px;"></div>
|
|
</div>
|
|
${progress}
|
|
</td>
|
|
<td>${job.Qualities?.length || 'Multiple'}</td>
|
|
<td>${startTime}</td>
|
|
<td>${eta}</td>
|
|
<td>
|
|
<button class="action-btn small" onclick="cancelJob('${jobId}')">Cancel</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
}
|
|
|
|
if (html === '') {
|
|
tbody.innerHTML = '<tr><td colspan="8" class="no-data">No active jobs</td></tr>';
|
|
} else {
|
|
tbody.innerHTML = html;
|
|
}
|
|
}
|
|
|
|
function updateJobHistoryTable(jobs) {
|
|
const tbody = document.getElementById('job-history-table');
|
|
if (!jobs || jobs.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="9" class="no-data">No job history</td></tr>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
jobs.forEach(job => {
|
|
const statusClass = job.status === 'completed' ? 'success' :
|
|
job.status === 'failed' ? 'error' : 'pending';
|
|
const createdAt = job.created_at ? new Date(job.created_at).toLocaleString() : 'Unknown';
|
|
const updatedAt = job.updated_at ? new Date(job.updated_at).toLocaleString() : 'Unknown';
|
|
|
|
html += `
|
|
<tr>
|
|
<td>${job.file_hash.substring(0, 8)}...</td>
|
|
<td>Video File</td>
|
|
<td><span class="status-badge ${statusClass}">${job.status}</span></td>
|
|
<td>${job.qualities || 'N/A'}</td>
|
|
<td>${job.duration || 'N/A'}</td>
|
|
<td>${createdAt}</td>
|
|
<td>${job.status === 'completed' ? updatedAt : 'N/A'}</td>
|
|
<td>${job.error || ''}</td>
|
|
<td>
|
|
${job.status === 'failed' ?
|
|
`<button class="action-btn small" onclick="retryJob('transcode_${job.file_hash}')">Retry</button>` :
|
|
'<span class="no-data">-</span>'
|
|
}
|
|
</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
|
|
tbody.innerHTML = html;
|
|
}
|
|
|
|
function estimateETA(progress, startTime) {
|
|
if (!progress || !startTime || progress <= 0) return 'Unknown';
|
|
|
|
const elapsed = Date.now() - new Date(startTime).getTime();
|
|
const remaining = (elapsed / progress) * (100 - progress);
|
|
|
|
if (remaining < 60000) return '< 1 min';
|
|
const minutes = Math.round(remaining / 60000);
|
|
return `~${minutes} min`;
|
|
}
|
|
|
|
function filterJobHistory() {
|
|
loadTranscodingJobs();
|
|
}
|
|
|
|
async function retryJob(jobId) {
|
|
if (!confirm('Are you sure you want to retry this job?')) return;
|
|
|
|
try {
|
|
const response = await adminFetch(`/api/admin/transcoding/retry/${jobId}`, { method: 'POST' });
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
showToast(result.message, 'success');
|
|
refreshTranscodingJobs();
|
|
} else {
|
|
showToast(result.error || 'Failed to retry job', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to retry job:', error);
|
|
showToast('Failed to retry job', 'error');
|
|
}
|
|
}
|
|
|
|
async function clearFailedJobs() {
|
|
if (!confirm('Are you sure you want to clear all failed jobs? This cannot be undone.')) return;
|
|
|
|
try {
|
|
const response = await adminFetch('/api/admin/transcoding/clear-failed', { method: 'POST' });
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
showToast(result.message, 'success');
|
|
refreshTranscodingJobs();
|
|
} else {
|
|
showToast(result.error || 'Failed to clear failed jobs', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to clear failed jobs:', error);
|
|
showToast('Failed to clear failed jobs', 'error');
|
|
}
|
|
}
|
|
|
|
async function retryFailedJobs() {
|
|
if (!confirm('Are you sure you want to retry all failed jobs?')) return;
|
|
|
|
try {
|
|
const response = await adminFetch('/api/admin/transcoding/retry-all-failed', { method: 'POST' });
|
|
const result = await response.json();
|
|
|
|
if (!response.ok) {
|
|
showToast('Failed to retry jobs: ' + (result.error || 'Unknown error'), 'error');
|
|
return;
|
|
}
|
|
|
|
showToast(result.message || `Successfully queued ${result.count || 0} jobs for retry`, 'success');
|
|
refreshTranscodingJobs();
|
|
|
|
} catch (error) {
|
|
console.error('Failed to retry failed jobs:', error);
|
|
showToast('Failed to retry failed jobs: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function pauseTranscoding() {
|
|
try {
|
|
const response = await adminFetch('/api/admin/transcoding/pause', { method: 'POST' });
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
showToast('Transcoding queue paused', 'info');
|
|
} else {
|
|
showToast(result.error || 'Failed to pause queue', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to pause transcoding:', error);
|
|
showToast('Feature not implemented yet', 'info');
|
|
}
|
|
}
|
|
|
|
async function cancelJob(jobId) {
|
|
if (!confirm('Are you sure you want to cancel this job?')) return;
|
|
|
|
showToast('Cancel job feature not implemented yet', 'info');
|
|
}
|
|
|
|
async function exportJobHistory() {
|
|
try {
|
|
const response = await adminFetch('/api/admin/transcoding/export');
|
|
const blob = await response.blob();
|
|
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'transcoding-history.csv';
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
showToast('Job history exported', 'success');
|
|
} catch (error) {
|
|
console.error('Failed to export job history:', error);
|
|
showToast('Export feature not implemented yet', 'info');
|
|
}
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
}
|
|
|
|
function showToast(message, type = 'info') {
|
|
const container = document.getElementById('toast-container');
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = message;
|
|
|
|
container.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.remove();
|
|
}, 5000);
|
|
}
|
|
|
|
// Load branding configuration
|
|
async function loadAdminBranding() {
|
|
try {
|
|
const response = await fetch('/api/branding');
|
|
if (response.ok) {
|
|
const branding = await response.json();
|
|
|
|
// Update admin dashboard title
|
|
if (branding.site_name) {
|
|
document.getElementById('admin-site-title').textContent = `🛡️ ${branding.site_name} Admin`;
|
|
document.title = `${branding.site_name} Admin`;
|
|
}
|
|
|
|
// Add logo if configured
|
|
if (branding.logo_url) {
|
|
const title = document.getElementById('admin-site-title');
|
|
const logo = document.createElement('img');
|
|
logo.src = branding.logo_url;
|
|
logo.alt = branding.site_name || 'Logo';
|
|
logo.style.height = '28px';
|
|
logo.style.width = 'auto';
|
|
logo.style.marginRight = '10px';
|
|
logo.style.verticalAlign = 'middle';
|
|
|
|
// Replace text with logo + text
|
|
title.innerHTML = '';
|
|
title.appendChild(logo);
|
|
title.appendChild(document.createTextNode(`🛡️ ${branding.site_name || 'BitTorrent Gateway'} Admin`));
|
|
}
|
|
|
|
// Update favicon if configured
|
|
if (branding.favicon_url) {
|
|
let favicon = document.querySelector('link[rel="icon"]');
|
|
if (!favicon) {
|
|
favicon = document.createElement('link');
|
|
favicon.rel = 'icon';
|
|
document.head.appendChild(favicon);
|
|
}
|
|
favicon.href = branding.favicon_url;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.log('Could not load branding configuration for admin, using defaults');
|
|
}
|
|
}
|
|
|
|
// Initialize
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadAdminBranding();
|
|
// Admin login event listener
|
|
document.getElementById('admin-nip07-login').addEventListener('click', async () => {
|
|
const result = await window.nostrAuth.loginNIP07();
|
|
if (result.success) {
|
|
checkAdminAuth();
|
|
} else {
|
|
document.getElementById('admin-login-status').textContent = result.message;
|
|
document.getElementById('admin-login-status').className = 'status error';
|
|
document.getElementById('admin-login-status').style.display = 'block';
|
|
}
|
|
});
|
|
|
|
// File filter change listeners
|
|
document.getElementById('file-storage-filter').addEventListener('change', loadFiles);
|
|
document.getElementById('file-access-filter').addEventListener('change', loadFiles);
|
|
document.getElementById('report-status-filter').addEventListener('change', loadReports);
|
|
|
|
// Check admin auth on load
|
|
checkAdminAuth();
|
|
});
|
|
|
|
// Close modals when clicking outside
|
|
window.addEventListener('click', (event) => {
|
|
const banModal = document.getElementById('ban-modal');
|
|
if (event.target === banModal) {
|
|
hideBanModal();
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html> |