Download Backend/fastapi/templates/admin_dashboard.html from dhyeye/streamio: direct link, hf CLI and curl.
- Browser
- Download file 28.2 kB
-
https://huggingface.co/spaces/dhyeye/streamio/resolve/main/Backend/fastapi/templates/admin_dashboard.html
- Command line
-
hf download hf://spaces/dhyeye/streamio/Backend/fastapi/templates/admin_dashboard.html
-
curl -L -o admin_dashboard.html https://huggingface.co/spaces/dhyeye/streamio/resolve/main/Backend/fastapi/templates/admin_dashboard.html
28.2 kB
| {% extends "base.html" %} | |
| {% block title %}Admin Dashboard - Telegram Stremio{% endblock %} | |
| {% block content %} | |
| <style> | |
| .glass-card { | |
| background: color-mix(in srgb, var(--card) 94%, var(--bg)); | |
| border: 1px solid color-mix(in srgb, var(--border) 95%, transparent); | |
| box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22); | |
| } | |
| .glass-card-soft { | |
| background: color-mix(in srgb, var(--card) 86%, var(--bg)); | |
| border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); | |
| } | |
| .section-title { | |
| color: var(--text); | |
| font-weight: 800; | |
| letter-spacing: -0.02em; | |
| } | |
| .muted { | |
| color: var(--text-sec); | |
| } | |
| .pill { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 0.45rem; | |
| padding: 0.45rem 0.85rem; | |
| border-radius: 9999px; | |
| font-size: 0.82rem; | |
| font-weight: 800; | |
| border: 1px solid color-mix(in srgb, var(--border) 82%, transparent); | |
| background: color-mix(in srgb, var(--bg) 66%, transparent); | |
| color: var(--text); | |
| white-space: nowrap; | |
| } | |
| .metric-card { | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .metric-card::before { | |
| content: ""; | |
| position: absolute; | |
| inset: -30% auto auto -10%; | |
| width: 140px; | |
| height: 140px; | |
| border-radius: 9999px; | |
| background: color-mix(in srgb, var(--primary) 12%, transparent); | |
| filter: blur(18px); | |
| pointer-events: none; | |
| } | |
| .metric-icon { | |
| width: 3.25rem; | |
| height: 3.25rem; | |
| border-radius: 1.1rem; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| background: color-mix(in srgb, var(--primary) 14%, transparent); | |
| color: var(--primary); | |
| border: 1px solid color-mix(in srgb, var(--primary) 18%, transparent); | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,.04); | |
| } | |
| .btn-ui { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 0.55rem; | |
| border-radius: 1rem; | |
| padding: 0.82rem 1.1rem; | |
| font-size: 0.92rem; | |
| font-weight: 800; | |
| transition: all 0.2s ease; | |
| border: 1px solid transparent; | |
| white-space: nowrap; | |
| } | |
| .btn-ui:hover { transform: translateY(-1px); } | |
| .btn-danger { | |
| background: color-mix(in srgb, rgb(239 68 68) 16%, transparent); | |
| color: rgb(248 113 113); | |
| border-color: color-mix(in srgb, rgb(239 68 68) 26%, transparent); | |
| } | |
| .btn-primary { | |
| background: var(--primary); | |
| color: white; | |
| box-shadow: 0 10px 24px color-mix(in srgb, var(--primary) 26%, transparent); | |
| } | |
| .btn-ghost { | |
| background: color-mix(in srgb, var(--bg) 74%, transparent); | |
| color: var(--text); | |
| border-color: color-mix(in srgb, var(--border) 95%, transparent); | |
| } | |
| .workload-row, | |
| .table-row-card { | |
| background: color-mix(in srgb, var(--bg) 68%, transparent); | |
| border: 1px solid color-mix(in srgb, var(--border) 90%, transparent); | |
| } | |
| .status-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 0.35rem; | |
| padding: 0.35rem 0.65rem; | |
| border-radius: 9999px; | |
| font-size: 0.72rem; | |
| font-weight: 800; | |
| letter-spacing: .05em; | |
| text-transform: uppercase; | |
| } | |
| .status-good { | |
| background: color-mix(in srgb, rgb(34 197 94) 16%, transparent); | |
| color: rgb(74 222 128); | |
| border: 1px solid color-mix(in srgb, rgb(34 197 94) 22%, transparent); | |
| } | |
| .status-warn { | |
| background: color-mix(in srgb, rgb(234 179 8) 16%, transparent); | |
| color: rgb(250 204 21); | |
| border: 1px solid color-mix(in srgb, rgb(234 179 8) 22%, transparent); | |
| } | |
| .status-bad { | |
| background: color-mix(in srgb, rgb(239 68 68) 16%, transparent); | |
| color: rgb(248 113 113); | |
| border: 1px solid color-mix(in srgb, rgb(239 68 68) 22%, transparent); | |
| } | |
| .table-shell { | |
| overflow: hidden; | |
| } | |
| .table-scroll { | |
| overflow-x: auto; | |
| max-height: 62vh; | |
| overflow-y: auto; | |
| } | |
| .table-ui { | |
| width: 100%; | |
| min-width: 920px; | |
| border-collapse: separate; | |
| border-spacing: 0; | |
| } | |
| .table-ui thead th { | |
| color: var(--text-sec); | |
| font-size: 0.72rem; | |
| font-weight: 800; | |
| text-transform: uppercase; | |
| letter-spacing: .06em; | |
| padding: 1rem 1.25rem; | |
| background: color-mix(in srgb, var(--card) 92%, var(--bg)); | |
| border-bottom: 1px solid color-mix(in srgb, var(--border) 92%, transparent); | |
| position: sticky; | |
| top: 0; | |
| z-index: 2; | |
| } | |
| .table-ui tbody td { | |
| padding: 1rem 1.25rem; | |
| border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent); | |
| color: var(--text); | |
| vertical-align: middle; | |
| } | |
| .table-ui tbody tr:hover td { | |
| background: color-mix(in srgb, var(--card) 18%, transparent); | |
| } | |
| .stream-id { | |
| max-width: 140px; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| display: inline-block; | |
| vertical-align: bottom; | |
| } | |
| .title-cell { | |
| max-width: 210px; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .empty-state { | |
| color: var(--text-sec); | |
| text-align: center; | |
| padding: 2rem 1rem; | |
| } | |
| .mobile-list { | |
| display: none; | |
| } | |
| .mobile-card { | |
| background: color-mix(in srgb, var(--bg) 70%, transparent); | |
| border: 1px solid color-mix(in srgb, var(--border) 90%, transparent); | |
| border-radius: 1.25rem; | |
| padding: 1rem; | |
| } | |
| .mobile-kv { | |
| display: grid; | |
| grid-template-columns: 110px 1fr; | |
| gap: 0.45rem 0.75rem; | |
| font-size: 0.86rem; | |
| } | |
| .mobile-kv .key { | |
| color: var(--text-sec); | |
| } | |
| .bot-grid { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 0.75rem; | |
| } | |
| .bot-card { | |
| background: color-mix(in srgb, var(--bg) 68%, transparent); | |
| border: 1px solid color-mix(in srgb, var(--border) 90%, transparent); | |
| border-radius: 1.1rem; | |
| padding: 0.85rem; | |
| } | |
| .bot-stat-num { | |
| font-size: 1.15rem; | |
| font-weight: 900; | |
| line-height: 1.1; | |
| } | |
| .bot-stat-label { | |
| font-size: 0.6rem; | |
| font-weight: 800; | |
| text-transform: uppercase; | |
| letter-spacing: .04em; | |
| color: var(--text-sec); | |
| margin-top: .15rem; | |
| } | |
| .analytics-mobile-card { | |
| padding: 0.75rem 0.85rem; | |
| border-radius: 1rem; | |
| } | |
| .scroll-cap { | |
| max-height: 62vh; | |
| overflow-y: auto; | |
| padding-right: 2px; | |
| } | |
| @media (min-width: 640px) { | |
| .bot-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } | |
| } | |
| @media (min-width: 1024px) { | |
| .bot-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } | |
| } | |
| .loading-dot { | |
| display: inline-block; | |
| width: .5rem; | |
| height: .5rem; | |
| border-radius: 9999px; | |
| background: var(--primary); | |
| box-shadow: 0 0 12px color-mix(in srgb, var(--primary) 40%, transparent); | |
| animation: pulseDot 1.2s infinite ease-in-out; | |
| } | |
| @keyframes pulseDot { | |
| 0%, 100% { opacity: .45; transform: scale(.9); } | |
| 50% { opacity: 1; transform: scale(1.1); } | |
| } | |
| @media (max-width: 1024px) { | |
| .table-ui { min-width: 840px; } | |
| } | |
| @media (max-width: 768px) { | |
| .desktop-table { | |
| display: none; | |
| } | |
| .mobile-list { | |
| display: grid; | |
| gap: 0.9rem; | |
| } | |
| .mobile-kv { | |
| grid-template-columns: 95px 1fr; | |
| } | |
| .btn-ui { | |
| width: 100%; | |
| } | |
| } | |
| </style> | |
| <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-5 sm:py-8 text-text"> | |
| <div class="glass-card rounded-3xl p-5 sm:p-6 lg:p-8 mb-6 sm:mb-8"> | |
| <div class="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-4"> | |
| <div> | |
| <div class="flex flex-wrap items-center gap-2 mb-3"> | |
| <span class="pill"><span class="loading-dot"></span> Live admin panel</span> | |
| </div> | |
| <h1 class="section-title text-2xl sm:text-3xl lg:text-4xl">Admin Dashboard</h1> | |
| <p class="muted mt-2 text-sm sm:text-base">Monitor system health, stream analytics, and flagged dead links from one responsive control panel.</p> | |
| </div> | |
| <div class="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full lg:w-auto"> | |
| <button onclick="clearCache()" class="btn-ui btn-danger"> | |
| <i class="fas fa-trash-alt"></i> | |
| Clear File Cache | |
| </button> | |
| <button onclick="clearAnalytics()" class="btn-ui btn-ghost"> | |
| <i class="fas fa-chart-line"></i> | |
| Clear Analytics | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-6 mb-6 sm:mb-8" id="stats-container"> | |
| <div class="glass-card metric-card rounded-3xl p-5 sm:p-6"> | |
| <div class="flex items-start gap-4"> | |
| <div class="metric-icon"> | |
| <i class="fas fa-memory text-xl"></i> | |
| </div> | |
| <div class="min-w-0"> | |
| <p class="muted text-sm font-semibold">Cached File IDs</p> | |
| <p class="text-3xl sm:text-4xl font-black tracking-tight mt-2 text-text" id="stat-cache-size">-</p> | |
| <p class="muted text-xs sm:text-sm mt-2">Current file-id cache entries in memory.</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="glass-card metric-card rounded-3xl p-5 sm:p-6"> | |
| <div class="flex items-start gap-4"> | |
| <div class="metric-icon"> | |
| <i class="fas fa-robot text-xl"></i> | |
| </div> | |
| <div class="min-w-0"> | |
| <p class="muted text-sm font-semibold">Connected Bots</p> | |
| <p class="text-3xl sm:text-4xl font-black tracking-tight mt-2 text-text" id="stat-bot-count">-</p> | |
| <p class="muted text-xs sm:text-sm mt-2">Active Telegram bot clients available right now.</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="glass-card rounded-3xl overflow-hidden mb-6 sm:mb-8"> | |
| <div class="px-5 sm:px-6 lg:px-8 py-4 sm:py-5 border-b border-hairline bg-surface flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> | |
| <div> | |
| <h2 class="section-title text-xl sm:text-2xl">Bot Health & Load</h2> | |
| <p class="muted text-sm mt-1">Track stream load, average speed, and failure trends per bot.</p> | |
| </div> | |
| <span class="pill">Realtime status</span> | |
| </div> | |
| <div id="stat-workloads" class="p-4 sm:p-5 lg:p-6"> | |
| <div class="empty-state">Loading bot data...</div> | |
| </div> | |
| </div> | |
| <div class="glass-card rounded-3xl overflow-hidden mb-6 sm:mb-8"> | |
| <div class="px-5 sm:px-6 lg:px-8 py-4 sm:py-5 border-b border-hairline bg-surface flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> | |
| <div> | |
| <h2 class="section-title text-xl sm:text-2xl">Recent Stream Analytics</h2> | |
| <p class="muted text-sm mt-1">Data transferred, speed, duration, and status for recent streams.</p> | |
| </div> | |
| <span class="pill">Recent activity</span> | |
| </div> | |
| <div class="p-4 sm:p-5 lg:p-6"> | |
| <div class="desktop-table table-shell"> | |
| <div class="table-scroll"> | |
| <table class="table-ui"> | |
| <thead> | |
| <tr> | |
| <th>Stream ID</th> | |
| <th>Title</th> | |
| <th class="text-center">Bot (DC)</th> | |
| <th class="text-right">Transferred</th> | |
| <th class="text-right">Avg / Peak</th> | |
| <th class="text-center">Duration</th> | |
| <th class="text-center">Status</th> | |
| <th class="text-right">Time</th> | |
| </tr> | |
| </thead> | |
| <tbody id="analytics-tbody"> | |
| <tr> | |
| <td colspan="8" class="empty-state"> | |
| <i class="fas fa-spinner fa-spin mr-2 text-primary"></i> Loading analytics... | |
| </td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div id="analytics-mobile" class="mobile-list scroll-cap"> | |
| <div class="empty-state"> | |
| <i class="fas fa-spinner fa-spin mr-2 text-primary"></i> Loading analytics... | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="glass-card rounded-3xl overflow-hidden"> | |
| <div class="px-5 sm:px-6 lg:px-8 py-4 sm:py-5 border-b border-hairline bg-surface flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> | |
| <div> | |
| <h2 class="section-title text-xl sm:text-2xl">Flagged Dead Links</h2> | |
| <p class="muted text-sm mt-1">Purge broken qualities so users no longer see dead content.</p> | |
| </div> | |
| <span class="pill">Database cleanup</span> | |
| </div> | |
| <div class="p-4 sm:p-5 lg:p-6"> | |
| <div class="desktop-table table-shell"> | |
| <div class="table-scroll"> | |
| <table class="table-ui"> | |
| <thead> | |
| <tr> | |
| <th>Title</th> | |
| <th>Type</th> | |
| <th>Quality Details</th> | |
| <th class="text-center">Date Added</th> | |
| <th class="text-center">Action</th> | |
| </tr> | |
| </thead> | |
| <tbody id="dead-links-tbody"> | |
| <tr> | |
| <td colspan="5" class="empty-state"> | |
| <i class="fas fa-spinner fa-spin mr-2 text-primary"></i> Loading dead links... | |
| </td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div id="dead-links-mobile" class="mobile-list scroll-cap"> | |
| <div class="empty-state"> | |
| <i class="fas fa-spinner fa-spin mr-2 text-primary"></i> Loading dead links... | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| function escapeHtml(value) { | |
| return String(value ?? '') | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| } | |
| function getStatusBadge(status) { | |
| const s = String(status || '').toLowerCase(); | |
| if (s === 'degraded' || s === 'cancelled') return 'status-badge status-warn'; | |
| if (s === 'failing' || s === 'error') return 'status-badge status-bad'; | |
| return 'status-badge status-good'; | |
| } | |
| async function fetchStats() { | |
| try { | |
| const res = await fetch('/api/admin/system-stats'); | |
| if (!res.ok) return; | |
| const data = await res.json(); | |
| document.getElementById('stat-cache-size').textContent = data.cache_size ?? '-'; | |
| document.getElementById('stat-bot-count').textContent = data.total_bots ?? '-'; | |
| const workloadsWrap = document.getElementById('stat-workloads'); | |
| if (data.bot_workloads && data.bot_workloads.length > 0) { | |
| let html = '<div class="bot-grid scroll-cap">'; | |
| data.bot_workloads.forEach(b => { | |
| const failCls = Number(b.failures ?? 0) > 0 ? 'text-red-400' : 'text-text'; | |
| html += ` | |
| <div class="bot-card"> | |
| <div class="flex items-center justify-between gap-2 mb-3"> | |
| <div class="font-bold text-text truncate">${escapeHtml(b.display_name)}</div> | |
| <span class="${getStatusBadge(b.status)}">${escapeHtml(b.status)}</span> | |
| </div> | |
| <div class="grid grid-cols-3 gap-2 text-center"> | |
| <div> | |
| <div class="bot-stat-num text-text">${b.current_load ?? 0}</div> | |
| <div class="bot-stat-label">Streams</div> | |
| </div> | |
| <div> | |
| <div class="bot-stat-num text-text">${Number(b.avg_mbps ?? 0).toFixed(1)}</div> | |
| <div class="bot-stat-label">MB/s</div> | |
| </div> | |
| <div> | |
| <div class="bot-stat-num ${failCls}">${b.failures ?? 0}</div> | |
| <div class="bot-stat-label">Fails</div> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| }); | |
| html += '</div>'; | |
| workloadsWrap.innerHTML = html; | |
| } else { | |
| workloadsWrap.innerHTML = '<div class="empty-state">No bot data available</div>'; | |
| } | |
| } catch (err) { | |
| console.error(err); | |
| showToast('Failed to load system stats.', 'error', 'Load Error'); | |
| } | |
| } | |
| async function fetchAnalytics() { | |
| try { | |
| const res = await fetch('/api/admin/stream-analytics'); | |
| const tbody = document.getElementById('analytics-tbody'); | |
| const mobile = document.getElementById('analytics-mobile'); | |
| if (!res.ok) { | |
| showToast('Failed to load stream analytics.', 'error', 'Load Error'); | |
| return; | |
| } | |
| const data = await res.json(); | |
| const streams = data?.data?.recent || []; | |
| if (!streams.length) { | |
| tbody.innerHTML = '<tr><td colspan="8" class="empty-state">No recent streams recorded.</td></tr>'; | |
| mobile.innerHTML = '<div class="empty-state">No recent streams recorded.</div>'; | |
| return; | |
| } | |
| let tableHtml = ''; | |
| let mobileHtml = ''; | |
| streams.forEach(s => { | |
| const avg = Number(s.avg_mbps ?? 0); | |
| const peak = Number(s.peak_mbps ?? 0); | |
| const bytesStr = ((Number(s.total_bytes ?? 0)) / (1024 * 1024)).toFixed(1) + ' MB'; | |
| const timeStr = s.logged_at ? new Date(s.logged_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '—'; | |
| const durationStr = s.duration_sec ? `${Number(s.duration_sec).toFixed(1)}s` : '—'; | |
| const titleStr = s.title || s.meta?.title || '—'; | |
| const status = s.status || 'unknown'; | |
| tableHtml += ` | |
| <tr> | |
| <td><span class="stream-id muted font-mono text-xs" title="${escapeHtml(s.stream_id)}">${escapeHtml(s.stream_id)}</span></td> | |
| <td><div class="title-cell font-semibold" title="${escapeHtml(titleStr)}">${escapeHtml(titleStr)}</div></td> | |
| <td class="text-center">Bot ${Number(s.client_index ?? 0) + 1} <span class="muted text-xs ml-1">(DC${escapeHtml(s.dc_id)})</span></td> | |
| <td class="text-right font-semibold">${bytesStr}</td> | |
| <td class="text-right font-semibold">${avg.toFixed(1)} <span class="muted">/ ${peak.toFixed(1)} MB/s</span></td> | |
| <td class="text-center muted">${durationStr}</td> | |
| <td class="text-center"><span class="${getStatusBadge(status)}">${escapeHtml(status)}</span></td> | |
| <td class="text-right muted text-xs">${timeStr}</td> | |
| </tr> | |
| `; | |
| mobileHtml += ` | |
| <div class="mobile-card analytics-mobile-card"> | |
| <div class="flex items-center justify-between gap-2"> | |
| <div class="font-semibold text-text truncate">${escapeHtml(titleStr)}</div> | |
| <span class="${getStatusBadge(status)}">${escapeHtml(status)}</span> | |
| </div> | |
| <div class="flex flex-wrap gap-x-3 gap-y-1 muted text-xs mt-2"> | |
| <span>Bot ${Number(s.client_index ?? 0) + 1} · DC${escapeHtml(s.dc_id)}</span> | |
| <span class="font-semibold text-text">${bytesStr}</span> | |
| <span>${avg.toFixed(1)}/${peak.toFixed(1)} MB/s</span> | |
| <span>${durationStr}</span> | |
| <span>${timeStr}</span> | |
| </div> | |
| </div> | |
| `; | |
| }); | |
| tbody.innerHTML = tableHtml; | |
| mobile.innerHTML = mobileHtml; | |
| } catch (err) { | |
| console.error(err); | |
| showToast('Failed to load stream analytics.', 'error', 'Load Error'); | |
| } | |
| } | |
| async function fetchDeadLinks() { | |
| try { | |
| const res = await fetch('/api/admin/dead-links'); | |
| const tbody = document.getElementById('dead-links-tbody'); | |
| const mobile = document.getElementById('dead-links-mobile'); | |
| if (!res.ok) { | |
| showToast('Failed to load dead links.', 'error', 'Load Error'); | |
| return; | |
| } | |
| const data = await res.json(); | |
| const links = data?.data || []; | |
| if (!links.length) { | |
| tbody.innerHTML = '<tr><td colspan="5" class="empty-state">No dead links found. All files are healthy!</td></tr>'; | |
| mobile.innerHTML = '<div class="empty-state">No dead links found. All files are healthy!</div>'; | |
| return; | |
| } | |
| let tableHtml = ''; | |
| let mobileHtml = ''; | |
| links.forEach(l => { | |
| const isMovie = l.type === 'movie'; | |
| const typeBadge = isMovie | |
| ? '<span class="status-badge status-good">Movie</span>' | |
| : '<span class="status-badge status-warn">TV Series</span>'; | |
| const dateAdded = l.date_added ? new Date(l.date_added).toLocaleDateString() : '—'; | |
| const qualityIdEsc = String(l.quality_id || '').replace(/'/g, "\\'"); | |
| tableHtml += ` | |
| <tr> | |
| <td> | |
| <div class="font-semibold text-text">${escapeHtml(l.title)}</div> | |
| <div class="muted text-xs mt-1">${escapeHtml(l.year || '')}</div> | |
| </td> | |
| <td>${typeBadge}</td> | |
| <td> | |
| <div class="font-semibold text-red-400">${escapeHtml(l.quality || 'Unknown')}</div> | |
| <div class="muted text-xs mt-1">${escapeHtml(l.size || 'Unknown size')}</div> | |
| </td> | |
| <td class="text-center muted text-xs">${dateAdded}</td> | |
| <td class="text-center"> | |
| <button onclick="deleteDeadLink(${l.tmdb_id}, ${l.db_index}, '${escapeHtml(l.type)}', '${qualityIdEsc}', ${l.season || -1}, ${l.episode || -1})" | |
| class="btn-ui btn-danger px-3 py-2 text-sm w-auto" | |
| title="Delete from Database"> | |
| <i class="fas fa-trash"></i> | |
| Delete | |
| </button> | |
| </td> | |
| </tr> | |
| `; | |
| mobileHtml += ` | |
| <div class="mobile-card"> | |
| <div class="flex items-start justify-between gap-3 mb-3"> | |
| <div> | |
| <div class="font-bold text-text">${escapeHtml(l.title)}</div> | |
| <div class="muted text-xs mt-1">${escapeHtml(l.year || '')}</div> | |
| </div> | |
| ${typeBadge} | |
| </div> | |
| <div class="mobile-kv mb-4"> | |
| <div class="key">Quality</div><div class="text-red-400 font-semibold">${escapeHtml(l.quality || 'Unknown')}</div> | |
| <div class="key">Size</div><div>${escapeHtml(l.size || 'Unknown size')}</div> | |
| <div class="key">Date Added</div><div>${dateAdded}</div> | |
| </div> | |
| <button onclick="deleteDeadLink(${l.tmdb_id}, ${l.db_index}, '${escapeHtml(l.type)}', '${qualityIdEsc}', ${l.season || -1}, ${l.episode || -1})" | |
| class="btn-ui btn-danger"> | |
| <i class="fas fa-trash"></i> | |
| Delete Dead Link | |
| </button> | |
| </div> | |
| `; | |
| }); | |
| tbody.innerHTML = tableHtml; | |
| mobile.innerHTML = mobileHtml; | |
| } catch (err) { | |
| console.error(err); | |
| showToast('Failed to load dead links.', 'error', 'Load Error'); | |
| } | |
| } | |
| async function clearCache() { | |
| if (!confirm("Are you sure you want to clear the Telegram FileId cache? This will force bots to re-fetch metadata on the next stream.")) return; | |
| try { | |
| const res = await fetch('/api/admin/clear-cache', { method: 'POST' }); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| showToast(data.message || 'Cache cleared successfully.', 'success', 'Cache Cleared'); | |
| fetchStats(); | |
| } else { | |
| showToast('Failed to clear cache.', 'error', 'Request Failed'); | |
| } | |
| } catch (err) { | |
| showToast('Error clearing cache.', 'error', 'Network Error'); | |
| } | |
| } | |
| async function clearAnalytics() { | |
| if (!confirm("Are you sure you want to clear all stream analytics history? This cannot be undone.")) return; | |
| try { | |
| const res = await fetch('/api/admin/clear-analytics', { method: 'POST' }); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| showToast(data.message || 'Analytics cleared successfully.', 'success', 'Analytics Cleared'); | |
| fetchAnalytics(); | |
| } else { | |
| const err = await res.json().catch(() => ({})); | |
| showToast(err.detail || 'Failed to clear analytics.', 'error', 'Request Failed'); | |
| } | |
| } catch (err) { | |
| showToast('Error clearing analytics.', 'error', 'Network Error'); | |
| } | |
| } | |
| async function deleteDeadLink(tmdb_id, db_index, type, quality_id, season, episode) { | |
| if (!confirm("Are you sure you want to purge this dead link from the database? Users will no longer see this quality in Stremio.")) return; | |
| try { | |
| let endpoint = ''; | |
| if (type === 'movie') { | |
| endpoint = `/api/media/delete-quality?tmdb_id=${tmdb_id}&db_index=${db_index}&id=${quality_id}`; | |
| } else { | |
| endpoint = `/api/media/delete-tv-quality?tmdb_id=${tmdb_id}&db_index=${db_index}&season=${season}&episode=${episode}&id=${quality_id}`; | |
| } | |
| const res = await fetch(endpoint, { method: 'DELETE' }); | |
| if (res.ok) { | |
| showToast('Dead link deleted successfully.', 'success', 'Cleanup Complete'); | |
| fetchDeadLinks(); | |
| } else { | |
| showToast('Failed to delete the dead link.', 'error', 'Delete Failed'); | |
| } | |
| } catch (err) { | |
| showToast('Error communicating with server.', 'error', 'Network Error'); | |
| } | |
| } | |
| document.addEventListener('DOMContentLoaded', () => { | |
| fetchStats(); | |
| fetchAnalytics(); | |
| fetchDeadLinks(); | |
| setInterval(fetchStats, 10000); | |
| setInterval(fetchAnalytics, 15000); | |
| }); | |
| </script> | |
| {% endblock %} | |