From 657683d4916854d41b1e165683af34375b356408 Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 9 May 2026 12:54:35 +0000 Subject: [PATCH] perf(sources): Quellen-Health Pagination (default 100, plus mehr/alle laden) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Echter Bottleneck war der DOM-Render von 519 Tabellen-Zeilen, nicht das Backend (45ms). Backend-Slim und Cache aus dem letzten Commit haben Bandbreite und wiederholte Klicks beschleunigt, aber der erste Klick blieb langsam, weil weiterhin alle 519 Items in einem innerHTML-Schub gerendert wurden. Lösung: Server-Side-Pagination. Backend (/api/sources/health): - Neue Query-Param: limit (default 100, max 5000), offset (default 0) - Counters errors/warnings/ok/total_checks aus separater GROUP-BY- Aggregat-Query über den GESAMTEN Bestand, nicht über die Page. - Neues Feld all_orgs in der Antwort: alle Tenants mit Health-Checks, damit das Filter-Dropdown auch im Pagination-Modus die volle Org-Liste hat. - Neue Felder limit, offset, has_more. Frontend (source-health.js): - healthLoadLimit (default 100), wird durch loadMoreHealth() um 200 hochgesetzt oder durch loadAllHealth() auf alles gesetzt. - Cache-Key beinhaltet jetzt auch das aktuelle Limit, damit beim Mehr-laden nicht aus altem Cache bedient wird. - Org-Liste kommt aus healthData.all_orgs statt aus den geladenen Page-Items, sonst wäre sie nach Pagination unvollständig. - Footer mit zwei Buttons ("+200 laden", "Alle N weiteren laden") unter der Tabelle, nur sichtbar bei has_more=true. - Counter-Anzeige: "X / Y angezeigt (von Z insgesamt)". Cache-Buster für source-health.js auf 20260509f gebumpt. --- src/routers/sources.py | 55 ++++++++++++++++++++++++++++----- src/static/dashboard.html | 2 +- src/static/js/source-health.js | 56 ++++++++++++++++++++++++++-------- 3 files changed, 93 insertions(+), 20 deletions(-) diff --git a/src/routers/sources.py b/src/routers/sources.py index 3d7f737..ef3522c 100644 --- a/src/routers/sources.py +++ b/src/routers/sources.py @@ -565,17 +565,46 @@ 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, billig) + cursor = await db.execute( + "SELECT status, COUNT(*) AS n FROM source_health_checks GROUP BY status" + ) + counts = {row["status"]: row["n"] for row in await cursor.fetchall()} + error_count = counts.get("error", 0) + warning_count = counts.get("warning", 0) + ok_count = counts.get("ok", 0) + total_checks = error_count + warning_count + ok_count + + # Paginierte Daten cursor = await db.execute(""" SELECT h.source_id, s.name, s.domain, s.tenant_id, s.language, @@ -587,12 +616,20 @@ async def get_health( 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() @@ -600,11 +637,15 @@ 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, "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 e4e7a8c..1d1448c 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -708,7 +708,7 @@ - +
diff --git a/src/static/js/source-health.js b/src/static/js/source-health.js index 5c45cc4..36c225a 100644 --- a/src/static/js/source-health.js +++ b/src/static/js/source-health.js @@ -8,9 +8,14 @@ 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. -let healthDataCache = { health: null, suggestions: null, history: null, ts: 0 }; +// 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", @@ -45,7 +50,10 @@ document.addEventListener("DOMContentLoaded", setupHealthTab); // --- Health-Daten laden --- async function loadHealthData(force = false) { const now = Date.now(); - if (!force && healthDataCache.health && (now - healthDataCache.ts) < HEALTH_CACHE_TTL_MS) { + if (!force + && healthDataCache.health + && healthDataCache.limit === healthLoadLimit + && (now - healthDataCache.ts) < HEALTH_CACHE_TTL_MS) { healthData = healthDataCache.health; suggestionsCache = healthDataCache.suggestions; healthHistoryCache = healthDataCache.history; @@ -54,14 +62,14 @@ async function loadHealthData(force = false) { } 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() }; + healthDataCache = { health, suggestions, history: history || [], ts: Date.now(), limit: healthLoadLimit }; renderHealthDashboard(); } catch (err) { console.error("Health-Daten laden fehlgeschlagen:", err); @@ -70,6 +78,17 @@ async function loadHealthData(force = false) { } } +// 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; @@ -179,14 +198,13 @@ 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))); @@ -219,7 +237,9 @@ function renderHealthDashboard() { ${orgs.map(o => ``).join("")} - ${filtered.length} / ${allChecks.length} Ergebnisse + + ${filtered.length} / ${allChecks.length} angezeigt${totalAll > allChecks.length ? ` (von ${totalAll} insgesamt)` : ''} + `; @@ -252,6 +272,18 @@ function renderHealthDashboard() { } 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 = `