mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-02 18:43:59 -06:00
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:
parent
0df9573caa
commit
1e9014f569
6 changed files with 261 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue