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