enki 9c22093aca
Some checks are pending
CI Pipeline / Run Tests (push) Waiting to run
CI Pipeline / Lint Code (push) Waiting to run
CI Pipeline / Security Scan (push) Waiting to run
CI Pipeline / E2E Tests (push) Blocked by required conditions
Improve upload error handling and admin transcoding monitoring
2025-08-27 17:15:30 -07:00

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()">&times;</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>