mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-03 02:53:58 -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,
|
error TEXT,
|
||||||
created_at 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 = """
|
_INDEXES = """
|
||||||
|
|
@ -334,3 +345,53 @@ async def update_session_last_seen(db: aiosqlite.Connection, session_id: str) ->
|
||||||
(now, session_id),
|
(now, session_id),
|
||||||
)
|
)
|
||||||
await db.commit()
|
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}
|
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")
|
@router.post("/purge")
|
||||||
async def manual_purge(
|
async def manual_purge(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
|
||||||
|
|
@ -446,6 +446,19 @@ class DownloadService:
|
||||||
"speed": None, "eta": None, "filename": None,
|
"speed": None, "eta": None, "filename": None,
|
||||||
"error_message": str(e)},
|
"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:
|
except Exception:
|
||||||
logger.exception("Job %s failed to update status after error", job_id)
|
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
|
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()
|
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:
|
else:
|
||||||
sessions_deleted = 0
|
sessions_deleted = 0
|
||||||
|
errors_cleared = 0
|
||||||
|
|
||||||
# Count skipped active jobs for observability
|
# Count skipped active jobs for observability
|
||||||
active_cursor = await db.execute(
|
active_cursor = await db.execute(
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import AdminLogin from './AdminLogin.vue'
|
||||||
const store = useAdminStore()
|
const store = useAdminStore()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const activeTab = ref<'sessions' | 'storage' | 'settings'>('sessions')
|
const activeTab = ref<'sessions' | 'storage' | 'errors' | 'settings'>('sessions')
|
||||||
|
|
||||||
// Session expansion state
|
// Session expansion state
|
||||||
const expandedSessions = ref<Set<string>>(new Set())
|
const expandedSessions = ref<Set<string>>(new Set())
|
||||||
|
|
@ -52,6 +52,7 @@ async function switchTab(tab: typeof activeTab.value) {
|
||||||
settingsSaved.value = false
|
settingsSaved.value = false
|
||||||
if (tab === 'sessions') await store.loadSessions()
|
if (tab === 'sessions') await store.loadSessions()
|
||||||
if (tab === 'storage') await store.loadStorage()
|
if (tab === 'storage') await store.loadStorage()
|
||||||
|
if (tab === 'errors') await store.loadErrorLog()
|
||||||
if (tab === 'settings') {
|
if (tab === 'settings') {
|
||||||
try {
|
try {
|
||||||
const config = await api.getPublicConfig()
|
const config = await api.getPublicConfig()
|
||||||
|
|
@ -178,7 +179,7 @@ function formatFilesize(bytes: number | null): string {
|
||||||
|
|
||||||
<div class="admin-tabs">
|
<div class="admin-tabs">
|
||||||
<button
|
<button
|
||||||
v-for="tab in (['sessions', 'storage', 'settings'] as const)"
|
v-for="tab in (['sessions', 'storage', 'errors', 'settings'] as const)"
|
||||||
:key="tab"
|
:key="tab"
|
||||||
:class="{ active: activeTab === tab }"
|
:class="{ active: activeTab === tab }"
|
||||||
@click="switchTab(tab)"
|
@click="switchTab(tab)"
|
||||||
|
|
@ -257,6 +258,38 @@ function formatFilesize(bytes: number | null): string {
|
||||||
<p v-else class="empty">Loading…</p>
|
<p v-else class="empty">Loading…</p>
|
||||||
</div>
|
</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 -->
|
<!-- Settings tab -->
|
||||||
<div v-if="activeTab === 'settings'" class="tab-content">
|
<div v-if="activeTab === 'settings'" class="tab-content">
|
||||||
<!-- All configurable settings in one form -->
|
<!-- All configurable settings in one form -->
|
||||||
|
|
@ -899,4 +932,82 @@ h3 {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-style: italic;
|
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>
|
</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 {
|
return {
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
|
|
@ -137,11 +174,14 @@ export const useAdminStore = defineStore('admin', () => {
|
||||||
storage,
|
storage,
|
||||||
purgeResult,
|
purgeResult,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
errorLog,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
loadSessions,
|
loadSessions,
|
||||||
loadStorage,
|
loadStorage,
|
||||||
triggerPurge,
|
triggerPurge,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
|
loadErrorLog,
|
||||||
|
clearErrorLog,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue