Phase 4: Admin-Übersicht erweitern (Stats-Bar + Health-Badge inline + Letzter Treffer)

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
Dieser Commit ist enthalten in:
claude-dev
2026-05-09 03:12:30 +00:00
Ursprung 9350e4538a
Commit 2001815e19
4 geänderte Dateien mit 166 neuen und 7 gelöschten Zeilen

Datei anzeigen

@@ -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),