Error log: failed download diagnostics for admin

Backend:
- New error_log table: url, domain, error, format_id, media_type,
  session_id, created_at
- log_download_error() called when yt-dlp throws during download
- GET /admin/errors returns recent entries (limit 200)
- DELETE /admin/errors clears all entries
- Manual purge also clears error log
- Domain extracted from URL via urlparse for grouping

Frontend:
- New 'Errors' tab in admin panel (Sessions, Storage, Errors, Settings)
- Each error entry shows: domain, timestamp, full URL, error message,
  format/media type metadata
- Red left border + error-colored message for visual scanning
- Clear Log button to wipe entries
- Empty state: 'No errors logged.'

Error entries contain enough context (full URL, error message, domain,
format, media type) to paste into an LLM for domain-specific debugging.
This commit is contained in:
xpltd 2026-03-19 06:34:08 -05:00
parent 0df9573caa
commit 1e9014f569
6 changed files with 261 additions and 3 deletions

View file

@ -60,6 +60,17 @@ CREATE TABLE IF NOT EXISTS unsupported_urls (
error TEXT,
created_at TEXT
);
CREATE TABLE IF NOT EXISTS error_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
domain TEXT,
error TEXT NOT NULL,
format_id TEXT,
media_type TEXT,
session_id TEXT,
created_at TEXT NOT NULL
);
"""
_INDEXES = """
@ -334,3 +345,53 @@ async def update_session_last_seen(db: aiosqlite.Connection, session_id: str) ->
(now, session_id),
)
await db.commit()
# ---------------------------------------------------------------------------
# Error log helpers
# ---------------------------------------------------------------------------
async def log_download_error(
db: aiosqlite.Connection,
url: str,
error: str,
session_id: str | None = None,
format_id: str | None = None,
media_type: str | None = None,
) -> None:
"""Record a failed download in the error log."""
from urllib.parse import urlparse
now = datetime.now(timezone.utc).isoformat()
try:
domain = urlparse(url).netloc or url[:80]
except Exception:
domain = url[:80]
await db.execute(
"""INSERT INTO error_log (url, domain, error, format_id, media_type, session_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(url, domain, error[:2000], format_id, media_type, session_id, now),
)
await db.commit()
async def get_error_log(
db: aiosqlite.Connection,
limit: int = 100,
) -> list[dict]:
"""Return recent error log entries, newest first."""
cursor = await db.execute(
"SELECT * FROM error_log ORDER BY created_at DESC LIMIT ?",
(limit,),
)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
async def clear_error_log(db: aiosqlite.Connection) -> int:
"""Delete all error log entries. Returns count deleted."""
cursor = await db.execute("DELETE FROM error_log")
await db.commit()
return cursor.rowcount

View file

@ -145,6 +145,32 @@ async def list_unsupported_urls(
return {"items": items, "total": total, "limit": limit, "offset": offset}
@router.get("/errors")
async def get_errors(
request: Request,
_admin: str = Depends(require_admin),
) -> dict:
"""Return recent download error log entries."""
from app.core.database import get_error_log
db = request.app.state.db
entries = await get_error_log(db, limit=200)
return {"errors": entries}
@router.delete("/errors")
async def clear_errors(
request: Request,
_admin: str = Depends(require_admin),
) -> dict:
"""Clear all error log entries."""
from app.core.database import clear_error_log
db = request.app.state.db
count = await clear_error_log(db)
return {"cleared": count}
@router.post("/purge")
async def manual_purge(
request: Request,

View file

@ -446,6 +446,19 @@ class DownloadService:
"speed": None, "eta": None, "filename": None,
"error_message": str(e)},
})
# Log to error_log table for admin visibility
from app.core.database import log_download_error
asyncio.run_coroutine_threadsafe(
log_download_error(
self._db,
url=url,
error=str(e),
session_id=session_id,
format_id=opts.get("format"),
media_type=opts.get("_media_type"),
),
self._loop,
)
except Exception:
logger.exception("Job %s failed to update status after error", job_id)

View file

@ -102,10 +102,17 @@ async def run_purge(
"""
)
sessions_deleted = orphan_cursor.rowcount
# Clear error log on manual purge
error_cursor = await db.execute("DELETE FROM error_log")
errors_cleared = error_cursor.rowcount
await db.commit()
logger.info("Purge: removed %d orphaned sessions", sessions_deleted)
logger.info("Purge: removed %d orphaned sessions, %d error log entries",
sessions_deleted, errors_cleared)
else:
sessions_deleted = 0
errors_cleared = 0
# Count skipped active jobs for observability
active_cursor = await db.execute(

View file

@ -9,7 +9,7 @@ import AdminLogin from './AdminLogin.vue'
const store = useAdminStore()
const configStore = useConfigStore()
const router = useRouter()
const activeTab = ref<'sessions' | 'storage' | 'settings'>('sessions')
const activeTab = ref<'sessions' | 'storage' | 'errors' | 'settings'>('sessions')
// Session expansion state
const expandedSessions = ref<Set<string>>(new Set())
@ -52,6 +52,7 @@ async function switchTab(tab: typeof activeTab.value) {
settingsSaved.value = false
if (tab === 'sessions') await store.loadSessions()
if (tab === 'storage') await store.loadStorage()
if (tab === 'errors') await store.loadErrorLog()
if (tab === 'settings') {
try {
const config = await api.getPublicConfig()
@ -178,7 +179,7 @@ function formatFilesize(bytes: number | null): string {
<div class="admin-tabs">
<button
v-for="tab in (['sessions', 'storage', 'settings'] as const)"
v-for="tab in (['sessions', 'storage', 'errors', 'settings'] as const)"
:key="tab"
:class="{ active: activeTab === tab }"
@click="switchTab(tab)"
@ -257,6 +258,38 @@ function formatFilesize(bytes: number | null): string {
<p v-else class="empty">Loading</p>
</div>
<!-- Errors tab -->
<div v-if="activeTab === 'errors'" class="tab-content">
<div class="error-log-header">
<p class="field-hint" style="margin: 0;">
Failed downloads are logged here with enough detail to diagnose domain-specific issues.
</p>
<button
v-if="store.errorLog.length"
@click="store.clearErrorLog()"
:disabled="store.isLoading"
class="btn-clear-log"
>
Clear Log
</button>
</div>
<div v-if="store.errorLog.length" class="error-log">
<div v-for="entry in store.errorLog" :key="entry.id" class="error-entry">
<div class="error-entry-header">
<span class="error-domain">{{ entry.domain }}</span>
<span class="error-time">{{ new Date(entry.created_at).toLocaleString() }}</span>
</div>
<div class="error-url mono">{{ entry.url }}</div>
<div class="error-message">{{ entry.error }}</div>
<div v-if="entry.format_id || entry.media_type" class="error-meta">
<span v-if="entry.media_type">{{ entry.media_type }}</span>
<span v-if="entry.format_id">format: {{ entry.format_id }}</span>
</div>
</div>
</div>
<p v-else class="empty">No errors logged.</p>
</div>
<!-- Settings tab -->
<div v-if="activeTab === 'settings'" class="tab-content">
<!-- All configurable settings in one form -->
@ -899,4 +932,82 @@ h3 {
font-size: var(--font-size-sm);
font-style: italic;
}
/* Error log */
.error-log-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-md);
}
.btn-clear-log {
background: var(--color-bg-elevated);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
padding: var(--space-xs) var(--space-md);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--font-size-sm);
white-space: nowrap;
}
.btn-clear-log:hover {
color: var(--color-error);
border-color: var(--color-error);
}
.error-log {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.error-entry {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-error);
border-radius: var(--radius-sm);
padding: var(--space-sm) var(--space-md);
}
.error-entry-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-xs);
}
.error-domain {
font-weight: 600;
color: var(--color-text);
}
.error-time {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.error-url {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
word-break: break-all;
margin-bottom: var(--space-xs);
}
.error-message {
font-size: var(--font-size-sm);
color: var(--color-error);
white-space: pre-wrap;
word-break: break-word;
}
.error-meta {
display: flex;
gap: var(--space-md);
margin-top: var(--space-xs);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
</style>

View file

@ -128,6 +128,43 @@ export const useAdminStore = defineStore('admin', () => {
}
}
// Error log
interface ErrorLogEntry {
id: number
url: string
domain: string | null
error: string
format_id: string | null
media_type: string | null
session_id: string | null
created_at: string
}
const errorLog = ref<ErrorLogEntry[]>([])
async function loadErrorLog(): Promise<void> {
const res = await fetch('/api/admin/errors', { headers: _authHeaders() })
if (res.ok) {
const data = await res.json()
errorLog.value = data.errors
}
}
async function clearErrorLog(): Promise<void> {
isLoading.value = true
try {
const res = await fetch('/api/admin/errors', {
method: 'DELETE',
headers: _authHeaders(),
})
if (res.ok) {
errorLog.value = []
}
} finally {
isLoading.value = false
}
}
return {
username,
password,
@ -137,11 +174,14 @@ export const useAdminStore = defineStore('admin', () => {
storage,
purgeResult,
isLoading,
errorLog,
login,
logout,
loadSessions,
loadStorage,
triggerPurge,
updateSettings,
loadErrorLog,
clearErrorLog,
}
})