diff --git a/src/routers/sources.py b/src/routers/sources.py index dd96608..7a3f8d7 100644 --- a/src/routers/sources.py +++ b/src/routers/sources.py @@ -565,35 +565,82 @@ async def add_discovered_sources( @router.get("/health") async def get_health( + limit: int = 100, + offset: int = 0, admin: dict = Depends(get_current_admin), db: aiosqlite.Connection = Depends(db_dependency), ): - """Health-Check-Ergebnisse abrufen.""" + """Health-Check-Ergebnisse abrufen. + + Default-Limit 100, sortiert nach Status (errors first, dann warnings, dann ok). + Counters (errors/warnings/ok/total_checks) beziehen sich auf den GESAMTEN + Datenbestand, nicht nur auf die zurückgegebene Page. Damit kann das Frontend + den vollen Status anzeigen, ohne alle Zeilen rendern zu müssen. + has_more zeigt an, ob es weitere Items zum Nachladen gibt. + all_orgs liefert die Liste aller Tenants mit Health-Checks (für Filter-Dropdown). + """ + limit = max(1, min(int(limit or 100), 5000)) + offset = max(0, int(offset or 0)) + # Prüfen ob Tabelle existiert cursor = await db.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks'" ) if not await cursor.fetchone(): - return {"last_check": None, "total_checks": 0, "errors": 0, "warnings": 0, "ok": 0, "checks": []} + return { + "last_check": None, "total_checks": 0, + "errors": 0, "warnings": 0, "ok": 0, + "checks": [], "all_orgs": [], + "limit": limit, "offset": offset, "has_more": False, + } + # Aggregate über GESAMTEN Bestand. Eine GROUP-BY-Query nach (check_type, status) + # liefert sowohl die Top-Counters als auch das feine Breakdown für die UI. + cursor = await db.execute( + "SELECT check_type, status, COUNT(*) AS n FROM source_health_checks GROUP BY check_type, status" + ) + breakdown = {} # {check_type: {status: count}} + error_count = 0 + warning_count = 0 + ok_count = 0 + for row in await cursor.fetchall(): + ct = row["check_type"] + st = row["status"] + breakdown.setdefault(ct, {})[st] = row["n"] + if st == "error": + error_count += row["n"] + elif st == "warning": + warning_count += row["n"] + elif st == "ok": + ok_count += row["n"] + total_checks = error_count + warning_count + ok_count + + # Paginierte Daten cursor = await db.execute(""" SELECT - h.id, h.source_id, s.name, s.domain, s.url, s.source_type, - s.tenant_id, s.category, s.language, s.bias, + h.source_id, s.name, s.domain, s.tenant_id, s.language, o.name AS org_name, - h.check_type, h.status, h.message, h.details, h.checked_at + h.check_type, h.status, h.message FROM source_health_checks h JOIN sources s ON s.id = h.source_id LEFT JOIN organizations o ON o.id = s.tenant_id ORDER BY CASE h.status WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END, s.name - """) + LIMIT ? OFFSET ? + """, (limit, offset)) checks = [dict(row) for row in await cursor.fetchall()] - error_count = sum(1 for c in checks if c["status"] == "error") - warning_count = sum(1 for c in checks if c["status"] == "warning") - ok_count = sum(1 for c in checks if c["status"] == "ok") + # Org-Liste (alle Tenants mit Health-Checks, für Frontend-Filter-Dropdown) + cursor = await db.execute(""" + SELECT DISTINCT s.tenant_id AS id, o.name AS name + FROM source_health_checks h + JOIN sources s ON s.id = h.source_id + LEFT JOIN organizations o ON o.id = s.tenant_id + WHERE s.tenant_id IS NOT NULL + ORDER BY o.name + """) + all_orgs = [dict(row) for row in await cursor.fetchall()] cursor = await db.execute("SELECT MAX(checked_at) as last_check FROM source_health_checks") row = await cursor.fetchone() @@ -601,11 +648,16 @@ async def get_health( return { "last_check": last_check, - "total_checks": len(checks), + "total_checks": total_checks, "errors": error_count, "warnings": warning_count, "ok": ok_count, + "breakdown": breakdown, "checks": checks, + "all_orgs": all_orgs, + "limit": limit, + "offset": offset, + "has_more": (offset + len(checks)) < total_checks, } diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 61b4d41..421014e 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -393,6 +393,13 @@ + +
+
+
+ + +
@@ -708,7 +715,7 @@ - +
diff --git a/src/static/js/source-health.js b/src/static/js/source-health.js index 6684da4..2927c94 100644 --- a/src/static/js/source-health.js +++ b/src/static/js/source-health.js @@ -3,9 +3,21 @@ let healthData = null; let suggestionsCache = []; -let healthFilters = { status: "", check_type: "", org: "all" }; +// Default-Filter zeigt nur Probleme (errors + warnings); OK ist meistens Rauschen. +// "issues" ist ein virtueller Status-Wert, den nur das Frontend versteht (siehe applyHealthFilter). +let healthFilters = { status: "issues", check_type: "", org: "all" }; let healthHistoryCache = []; +// 60-Sekunden-Cache, damit Tab-Wechsel nicht jedes Mal die volle Antwort neu lädt. +// Bei Mutationen (Vorschlag annehmen/ablehnen, run-stream, search-fix) wird mit force=true neu geladen. +// Cache-Key beinhaltet das aktuelle Limit, damit "Mehr laden" nicht aus altem Cache bedient wird. +let healthDataCache = { health: null, suggestions: null, history: null, ts: 0, limit: 0 }; +const HEALTH_CACHE_TTL_MS = 60000; + +// Default-Pagination: 100 Items reichen meistens (errors+warnings stehen vorne, ok-Status hinten). +// Wird durch loadMoreHealth() / loadAllHealth() hochgesetzt. +let healthLoadLimit = 100; + const CHECK_TYPE_LABELS = { reachability: "Erreichbarkeit", @@ -38,16 +50,28 @@ function setupHealthTab() { document.addEventListener("DOMContentLoaded", setupHealthTab); // --- Health-Daten laden --- -async function loadHealthData() { +async function loadHealthData(force = false) { + const now = Date.now(); + if (!force + && healthDataCache.health + && healthDataCache.limit === healthLoadLimit + && (now - healthDataCache.ts) < HEALTH_CACHE_TTL_MS) { + healthData = healthDataCache.health; + suggestionsCache = healthDataCache.suggestions; + healthHistoryCache = healthDataCache.history; + renderHealthDashboard(); + return; + } try { const [health, suggestions, history] = await Promise.all([ - API.get("/api/sources/health"), + API.get("/api/sources/health?limit=" + healthLoadLimit), API.get("/api/sources/suggestions"), API.get("/api/sources/health/history?limit=10").catch(() => []), ]); healthData = health; suggestionsCache = suggestions; healthHistoryCache = history || []; + healthDataCache = { health, suggestions, history: history || [], ts: Date.now(), limit: healthLoadLimit }; renderHealthDashboard(); } catch (err) { console.error("Health-Daten laden fehlgeschlagen:", err); @@ -56,9 +80,22 @@ async function loadHealthData() { } } +// Pagination-Steuerung: hochsetzen + neu laden +function loadMoreHealth() { + healthLoadLimit += 200; + loadHealthData(true); +} + +function loadAllHealth() { + healthLoadLimit = 100000; + loadHealthData(true); +} + function applyHealthFilter(checks) { return checks.filter(c => { - if (healthFilters.status && c.status !== healthFilters.status) return false; + // "issues" = Sammelfilter für errors + warnings (Default) + if (healthFilters.status === "issues" && c.status === "ok") return false; + if (healthFilters.status && healthFilters.status !== "issues" && c.status !== healthFilters.status) return false; if (healthFilters.check_type && c.check_type !== healthFilters.check_type) return false; if (healthFilters.org === "global" && c.tenant_id !== null) return false; if (healthFilters.org !== "all" && healthFilters.org !== "global" @@ -165,17 +202,31 @@ function renderHealthDashboard() { // Filter anwenden const allChecks = healthData.checks; const filtered = applyHealthFilter(allChecks); - const okCount = healthData.checks.filter((c) => c.status === "ok").length; + // Counters aus Backend-Aggregat (über Gesamt-Bestand, nicht nur Page) + const okCount = healthData.ok != null ? healthData.ok : healthData.checks.filter((c) => c.status === "ok").length; + const totalAll = healthData.total_checks != null ? healthData.total_checks : allChecks.length; + const hasMore = !!healthData.has_more; - // Org-Liste fuer Dropdown - const orgs = Array.from(new Set(allChecks.map(c => c.tenant_id).filter(t => t != null))) - .map(tid => { - const c = allChecks.find(x => x.tenant_id === tid); - return { id: String(tid), name: c ? c.org_name : `Org ${tid}` }; - }); + // Org-Liste aus Backend-Liste (volle Liste, auch wenn Page kleiner ist) + const orgs = (healthData.all_orgs || []).map(o => ({ id: String(o.id), name: o.name || ("Org " + o.id) })); const checkTypes = Array.from(new Set(allChecks.map(c => c.check_type))); + // Counter-Aufgliederung aus Backend-Breakdown (pro check_type x status). + // Beispiel: { reachability: {ok: 281, error: 3, warning: 1}, feed_validity: {...}, stale: {...}, duplicate: {...} } + const breakdown = healthData.breakdown || {}; + function breakdownLine(statusKey, cssClass) { + const entries = Object.entries(breakdown) + .map(([ct, byStatus]) => [ct, byStatus[statusKey] || 0]) + .filter(([_, n]) => n > 0) + .sort((a, b) => b[1] - a[1]); + if (entries.length === 0) return ""; + const total = entries.reduce((s, [, n]) => s + n, 0); + const detail = entries.map(([ct, n]) => `${n} ${CHECK_TYPE_LABELS[ct] || ct}`).join(", "); + const label = statusKey === "error" ? "Fehler" : (statusKey === "warning" ? "Warnungen" : "OK"); + return `${total} ${label} (${esc(detail)})`; + } + healthHtml = `
@@ -183,14 +234,15 @@ function renderHealthDashboard() { Letzter Check: ${healthData.last_check ? formatDateTime(healthData.last_check) : "Noch nie"}  |  - ${healthData.errors} Fehler   - ${healthData.warnings} Warnungen   + ${breakdownLine("error", "text-danger") || `0 Fehler`}   + ${breakdownLine("warning", "text-warning") || `0 Warnungen`}   ${okCount} OK
- ${filtered.length} / ${allChecks.length} Ergebnisse + + ${filtered.length} / ${allChecks.length} angezeigt${totalAll > allChecks.length ? ` (von ${totalAll} insgesamt)` : ''} +
`; @@ -235,9 +289,31 @@ function renderHealthDashboard() {
`; + } else if (hasMore) { + // 0 Treffer in der Page, aber es gibt noch ungeladene Items. + // Hinweis, dass der Filter erst über die volle Liste sicher ist. + healthHtml += ` +
+ Keine Treffer in den geladenen ${allChecks.length} von ${totalAll} Items mit dem aktuellen Filter. + + Alle ${totalAll} Health-Checks laden + und Filter erneut anwenden. +
`; } else { healthHtml += '
Keine Ergebnisse mit diesen Filtern.
'; } + + // Footer mit Mehr-laden-Buttons, falls Backend has_more meldet + if (hasMore) { + const remaining = Math.max(0, totalAll - allChecks.length); + healthHtml += ` +
+ ${allChecks.length} von ${totalAll} geladen + + +
`; + } + healthHtml += "
"; } else { healthHtml = ` @@ -289,7 +365,7 @@ async function handleSuggestion(id, accept) { if (result.action) { showToast("Ergebnis: " + result.action, "success"); } - loadHealthData(); + loadHealthData(true); // Grundquellen-Liste auch aktualisieren if (typeof loadGlobalSources === "function") loadGlobalSources(); } catch (err) { @@ -371,7 +447,7 @@ async function runHealthCheck() { } } - loadHealthData(); + loadHealthData(true); } catch (err) { progressEl.innerHTML = 'Fehler: ' + esc(err.message) + ''; } finally { @@ -419,7 +495,7 @@ async function searchFix(btn) { msg += `\n\nKosten: $${result.cost_usd.toFixed(2)}`; } showToast(msg, "info"); - loadHealthData(); + loadHealthData(true); } catch (err) { showToast("Fehler: " + err.message, "error"); } finally {