From 2001815e19c95b0522dc3f86319bbf9f0398e2ea Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 9 May 2026 03:12:30 +0000 Subject: [PATCH] =?UTF-8?q?Phase=204:=20Admin-=C3=9Cbersicht=20erweitern?= =?UTF-8?q?=20(Stats-Bar=20+=20Health-Badge=20inline=20+=20Letzter=20Treff?= =?UTF-8?q?er)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend - routers/sources.py: - GET /api/sources/global/stats NEU: aggregierte Counter nach Typ, Total-Articles, Health-Bilanz (errors/warnings/ok) - GET /api/sources/global liefert pro Quelle health_status (worst-case error > warning > ok, NULL wenn nie gecheckt) Frontend - dashboard.html sub-global-sources: Stats-Bar Container oben. Tabellenkopf bekommt zwei neue Spalten: Letzter Treffer + Health. - style.css: .sources-stats-bar (analog Monitor-Style), .health-badge mit Varianten error/warning/ok/unknown. - sources.js: - loadGlobalSources lädt parallel /global + /global/stats - renderGlobalStats: rendert Stats-Bar mit Total-Quellen, Counts pro Typ (aus META), Total-Articles, Health-Counters - renderGlobalSources: 9 Spalten statt 7, Letzter-Treffer + Health-Badge, typeLabel statt TYPE_LABELS-Direktzugriff --- src/routers/sources.py | 88 +++++++++++++++++++++++++++++++++++++-- src/static/css/style.css | 41 ++++++++++++++++++ src/static/dashboard.html | 3 ++ src/static/js/sources.js | 41 ++++++++++++++++-- 4 files changed, 166 insertions(+), 7 deletions(-) diff --git a/src/routers/sources.py b/src/routers/sources.py index df78cb7..4d0f5b5 100644 --- a/src/routers/sources.py +++ b/src/routers/sources.py @@ -65,10 +65,36 @@ async def list_global_sources( admin: dict = Depends(get_current_admin), db: aiosqlite.Connection = Depends(db_dependency), ): - """Alle Grundquellen auflisten (tenant_id IS NULL).""" - cursor = await db.execute( - "SELECT * FROM sources WHERE tenant_id IS NULL ORDER BY category, source_type, name" - ) + """Alle Grundquellen auflisten (tenant_id IS NULL). + + Liefert pro Quelle den worst-case Health-Status aus source_health_checks + (error > warning > ok > unknown). Damit kann das Frontend ein Inline-Badge + pro Zeile zeigen, ohne separate Health-Tab-Abfrage. + """ + cursor = await db.execute(""" + SELECT s.*, + COALESCE(( + SELECT CASE + WHEN MAX(CASE WHEN h.status = 'error' THEN 3 + WHEN h.status = 'warning' THEN 2 + WHEN h.status = 'ok' THEN 1 + ELSE 0 END) = 3 THEN 'error' + WHEN MAX(CASE WHEN h.status = 'error' THEN 3 + WHEN h.status = 'warning' THEN 2 + WHEN h.status = 'ok' THEN 1 + ELSE 0 END) = 2 THEN 'warning' + WHEN MAX(CASE WHEN h.status = 'error' THEN 3 + WHEN h.status = 'warning' THEN 2 + WHEN h.status = 'ok' THEN 1 + ELSE 0 END) = 1 THEN 'ok' + ELSE NULL + END + FROM source_health_checks h WHERE h.source_id = s.id + ), NULL) AS health_status + FROM sources s + WHERE s.tenant_id IS NULL + ORDER BY s.category, s.source_type, s.name + """) return [dict(row) for row in await cursor.fetchall()] @@ -174,6 +200,60 @@ async def delete_global_source( ) + + + +@router.get("/global/stats") +async def get_global_stats( + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Aggregierte Stats für die Grundquellen-Stats-Bar oben im Tab.""" + cur = await db.execute(""" + SELECT source_type, COUNT(*) AS count, COALESCE(SUM(article_count), 0) AS articles + FROM sources + WHERE tenant_id IS NULL AND status = 'active' + GROUP BY source_type + """) + by_type = {} + total = 0 + total_articles = 0 + for r in await cur.fetchall(): + d = dict(r) + by_type[d["source_type"]] = {"count": d["count"], "articles": d["articles"]} + total += d["count"] + total_articles += d["articles"] + + # Health-Counter + health = {"errors": 0, "warnings": 0, "ok": 0} + cur = await db.execute(""" + SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks' + """) + if await cur.fetchone(): + cur = await db.execute(""" + SELECT h.status AS hs, COUNT(DISTINCT h.source_id) AS cnt + FROM source_health_checks h + JOIN sources s ON s.id = h.source_id + WHERE s.tenant_id IS NULL AND s.status = 'active' + GROUP BY h.status + """) + for r in await cur.fetchall(): + d = dict(r) + if d["hs"] == "error": + health["errors"] = d["cnt"] + elif d["hs"] == "warning": + health["warnings"] = d["cnt"] + elif d["hs"] == "ok": + health["ok"] = d["cnt"] + + return { + "by_type": by_type, + "total": total, + "total_articles": total_articles, + "health": health, + } + + @router.get("/tenant") async def list_tenant_sources( admin: dict = Depends(get_current_admin), diff --git a/src/static/css/style.css b/src/static/css/style.css index 0d4a7cf..aa4876d 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -831,3 +831,44 @@ input[type="date"].filter-select { padding: 6px 10px; } from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(20px); } } + +/* === Sources Stats-Bar (Phase 4) === */ +.sources-stats-bar { + display: flex; + flex-wrap: wrap; + gap: 14px; + padding: 12px 16px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + margin-bottom: 14px; + font-size: 13px; +} +.sources-stat-item { + display: inline-flex; + align-items: baseline; + gap: 6px; + color: #94a3b8; +} +.sources-stat-value { + color: #f0b429; + font-weight: 600; + font-size: 15px; +} +.sources-stat-item.health-error .sources-stat-value { color: #ef4444; } +.sources-stat-item.health-warning .sources-stat-value { color: #f59e0b; } +.sources-stat-item.health-ok .sources-stat-value { color: #10b981; } + +/* Health-Badge inline in Tabellenzeile */ +.health-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; +} +.health-badge-error { background: rgba(239, 68, 68, 0.15); color: #ef4444; } +.health-badge-warning { background: rgba(245, 158, 11, 0.15); color: #f59e0b; } +.health-badge-ok { background: rgba(16, 185, 129, 0.15); color: #10b981; } +.health-badge-unknown { background: rgba(148, 163, 184, 0.15); color: #94a3b8; } + diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 3666736..229c5be 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -298,6 +298,7 @@
+
@@ -327,6 +328,8 @@ Domain Typ Artikel + Letzter Treffer + Health Status Aktionen diff --git a/src/static/js/sources.js b/src/static/js/sources.js index 9d3fdbf..b4f0770 100644 --- a/src/static/js/sources.js +++ b/src/static/js/sources.js @@ -50,16 +50,45 @@ async function loadGlobalSources() { populateSelect(document.getElementById("globalFilterType"), (window.META.types || []).filter(t => t.key !== "excluded"), "Alle Typen"); } - globalSourcesCache = await API.get("/api/sources/global"); + const [list, stats] = await Promise.all([ + API.get("/api/sources/global"), + API.get("/api/sources/global/stats"), + ]); + globalSourcesCache = list; + renderGlobalStats(stats); renderGlobalSources(globalSourcesCache); } catch (err) { console.error("Grundquellen laden fehlgeschlagen:", err); } } + +function renderGlobalStats(stats) { + const bar = document.getElementById("globalStatsBar"); + if (!bar) return; + if (!stats || !stats.by_type) { bar.innerHTML = ""; return; } + + const types = window.META && window.META.types ? window.META.types : []; + const parts = []; + parts.push(`${stats.total || 0} Quellen gesamt`); + for (const t of types) { + if (t.key === "excluded") continue; + const v = stats.by_type[t.key] || { count: 0, articles: 0 }; + parts.push(`${v.count} ${esc(t.label)}`); + } + parts.push(`${stats.total_articles || 0} Artikel`); + + const h = stats.health || { errors: 0, warnings: 0, ok: 0 }; + if (h.errors) parts.push(`${h.errors} Fehler`); + if (h.warnings) parts.push(`${h.warnings} Warnungen`); + if (h.ok) parts.push(`${h.ok} OK`); + + bar.innerHTML = parts.join(""); +} + function renderGlobalSources(sources) { const tbody = document.getElementById("globalSourceTable"); - const cols = 7; + const cols = 9; if (sources.length === 0) { tbody.innerHTML = `Keine Grundquellen`; return; @@ -87,12 +116,18 @@ function renderGlobalSources(sources) { const notesRow = hasNotes ? `${esc(s.notes)}` : ''; + const lastSeen = s.last_seen_at ? formatDate(s.last_seen_at) : "-"; + const hs = s.health_status || "unknown"; + const hsLabel = { error: "Fehler", warning: "Warnung", ok: "OK", unknown: "—" }[hs]; + const hsClass = "health-badge-" + (hs === "unknown" ? "unknown" : hs); html += ` ${infoBtn} ${esc(s.name)} ${esc(s.url || "-")} ${esc(s.domain || "-")} - ${TYPE_LABELS[s.source_type] || s.source_type} + ${typeLabel(s.source_type)} ${s.article_count || 0} + ${lastSeen} + ${hsLabel} ${s.status === "active" ? "Aktiv" : "Inaktiv"}