diff --git a/src/routers/sources.py b/src/routers/sources.py index 8f2c251..e45cb28 100644 --- a/src/routers/sources.py +++ b/src/routers/sources.py @@ -144,18 +144,27 @@ class GlobalSourceUpdate(BaseModel): alignments: Optional[list[str]] = None -@router.get("/global") -async def list_global_sources( +@router.get("") +async def list_sources( + scope: str = "all", admin: dict = Depends(get_current_admin), db: aiosqlite.Connection = Depends(db_dependency), ): - """Alle Grundquellen auflisten (tenant_id IS NULL). + """Vereinte Quellenliste (Grund- und Kundenquellen). - 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. + scope = all | global | tenant. Liefert pro Quelle den worst-case + Health-Status aus source_health_checks (error > warning > ok > unknown) + und den Organisationsnamen bei Kundenquellen (org_name, NULL bei global). """ - cursor = await db.execute(""" + scope_where = { + "all": "1=1", + "global": "s.tenant_id IS NULL", + "tenant": "s.tenant_id IS NOT NULL", + }.get(scope) + if scope_where is None: + raise HTTPException(status_code=422, detail="scope muss all, global oder tenant sein") + + cursor = await db.execute(f""" WITH article_stats AS ( -- Match per source-Name (case-insensitive). source_url im articles ist die -- Artikel-URL, nicht die Feed-URL - daher matcht das nicht mit sources.url. @@ -177,6 +186,7 @@ async def list_global_sources( GROUP BY source_id ) SELECT s.*, + o.name AS org_name, CASE ha.rank WHEN 3 THEN 'error' WHEN 2 THEN 'warning' @@ -186,16 +196,13 @@ async def list_global_sources( COALESCE(ast.a7d, 0) AS articles_7d, COALESCE(ast.a30d, 0) AS articles_30d FROM sources s + LEFT JOIN organizations o ON o.id = s.tenant_id LEFT JOIN article_stats ast ON ast.s_lower = LOWER(s.name) LEFT JOIN health_agg ha ON ha.source_id = s.id - WHERE s.tenant_id IS NULL + WHERE {scope_where} ORDER BY s.category, s.source_type, s.name """) - rows = [dict(row) for row in await cursor.fetchall()] - alignments_map = await _load_alignments_for(db, [r["id"] for r in rows]) - for r in rows: - r["alignments"] = alignments_map.get(r["id"], []) - return rows + return [dict(row) for row in await cursor.fetchall()] @router.post("/global", status_code=201) @@ -245,13 +252,17 @@ async def update_global_source( admin: dict = Depends(get_current_admin), db: aiosqlite.Connection = Depends(db_dependency), ): - """Grundquelle bearbeiten — inkl. Klassifikation + alignments.""" + """Quelle bearbeiten (Grund- und Kundenquellen) — inkl. Klassifikation + alignments. + + Kundenquellen sind absichtlich mit erlaubt, damit der Deaktivieren-Knopf + der vereinten Liste (status=inactive) fuer beide Herkuenfte funktioniert. + """ cursor = await db.execute( - "SELECT * FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,) + "SELECT * FROM sources WHERE id = ?", (source_id,) ) row = await cursor.fetchone() if not row: - raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden") + raise HTTPException(status_code=404, detail="Quelle nicht gefunden") before = dict(row) before_alignments = sorted((await _load_alignments_for(db, [source_id])).get(source_id, [])) @@ -336,31 +347,35 @@ async def delete_global_source( -@router.get("/global/languages") -async def get_global_languages( +@router.get("/languages") +async def get_languages( admin: dict = Depends(get_current_admin), db: aiosqlite.Connection = Depends(db_dependency), ): - """Distinct language-Werte aus Grundquellen - für Frontend-Filter-Dropdown.""" + """Distinct language-Werte aller Quellen - für Frontend-Filter-Dropdown.""" cur = await db.execute(""" SELECT DISTINCT language FROM sources - WHERE tenant_id IS NULL AND language IS NOT NULL AND language != '' + WHERE language IS NOT NULL AND language != '' ORDER BY language """) return [r["language"] for r in await cur.fetchall()] -@router.get("/global/stats") -async def get_global_stats( +@router.get("/stats") +async def get_sources_stats( admin: dict = Depends(get_current_admin), db: aiosqlite.Connection = Depends(db_dependency), ): - """Aggregierte Stats für die Grundquellen-Stats-Bar oben im Tab.""" + """Aggregierte Stats für die Stats-Bar der vereinten Quellenliste. + + Zählt Grund- UND Kundenquellen. by_origin liefert die Aufteilung, + last_check den Zeitpunkt des jüngsten Health-Checks. + """ 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' + WHERE status = 'active' GROUP BY source_type """) by_type = {} @@ -372,8 +387,18 @@ async def get_global_stats( total += d["count"] total_articles += d["articles"] - # Health-Counter + cur = await db.execute(""" + SELECT CASE WHEN tenant_id IS NULL THEN 'global' ELSE 'tenant' END AS origin, + COUNT(*) AS cnt + FROM sources + WHERE status = 'active' + GROUP BY origin + """) + by_origin = {dict(r)["origin"]: dict(r)["cnt"] for r in await cur.fetchall()} + + # Health-Counter + Zeitpunkt des letzten Checks health = {"errors": 0, "warnings": 0, "ok": 0} + last_check = None cur = await db.execute(""" SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks' """) @@ -382,7 +407,7 @@ async def get_global_stats( 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' + WHERE s.status = 'active' GROUP BY h.status """) for r in await cur.fetchall(): @@ -393,31 +418,21 @@ async def get_global_stats( health["warnings"] = d["cnt"] elif d["hs"] == "ok": health["ok"] = d["cnt"] + cur = await db.execute("SELECT MAX(checked_at) AS lc FROM source_health_checks") + row = await cur.fetchone() + if row: + last_check = dict(row)["lc"] return { "by_type": by_type, + "by_origin": by_origin, "total": total, "total_articles": total_articles, "health": health, + "last_check": last_check, } -@router.get("/tenant") -async def list_tenant_sources( - admin: dict = Depends(get_current_admin), - db: aiosqlite.Connection = Depends(db_dependency), -): - """Alle tenant-spezifischen Quellen mit Org-Name auflisten.""" - cursor = await db.execute(""" - SELECT s.*, o.name as org_name - FROM sources s - LEFT JOIN organizations o ON o.id = s.tenant_id - WHERE s.tenant_id IS NOT NULL - ORDER BY o.name, s.category, s.name - """) - return [dict(row) for row in await cursor.fetchall()] - - @router.post("/tenant/{source_id}/promote") async def promote_to_global( source_id: int, @@ -799,6 +814,36 @@ async def get_health_history( return [dict(row) for row in await cursor.fetchall()] +@router.get("/{source_id}/health") +async def get_source_health( + source_id: int, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Alle Health-Checks einer einzelnen Quelle (für die Ausklapp-Zeile der Liste). + + Sortiert error > warning > ok, damit das Wichtigste oben steht. + """ + cursor = await db.execute("SELECT id FROM sources WHERE id = ?", (source_id,)) + if not await cursor.fetchone(): + raise HTTPException(status_code=404, detail="Quelle nicht gefunden") + + cursor = await db.execute(""" + SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks' + """) + if not await cursor.fetchone(): + return {"source_id": source_id, "checks": []} + + cursor = await db.execute(""" + SELECT check_type, status, message, checked_at + FROM source_health_checks + WHERE source_id = ? + ORDER BY CASE status WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END, + check_type + """, (source_id,)) + return {"source_id": source_id, "checks": [dict(row) for row in await cursor.fetchall()]} + + @router.get("/suggestions") async def get_suggestions( admin: dict = Depends(get_current_admin), diff --git a/src/static/css/style.css b/src/static/css/style.css index 794eab0..20b0eb7 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -920,6 +920,34 @@ input[type="date"].filter-select { padding: 6px 10px; } .health-badge-ok { background: rgba(16, 185, 129, 0.15); color: #10b981; } .health-badge-unknown { background: rgba(148, 163, 184, 0.15); color: #94a3b8; } +/* Klickbares Health-Badge oeffnet die Detailzeile */ +.health-badge-btn { cursor: pointer; } +.health-badge-btn:hover { filter: brightness(1.25); text-decoration: underline; } + +/* Checkbox-Spalte: nur sichtbar bei Herkunfts-Filter "Kundenquellen" */ +.select-col { display: none; } +table.show-select .select-col { display: table-cell; } + +/* Health-Details in der Ausklapp-Zeile */ +.src-notes-text { margin-bottom: 8px; } +.health-detail-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 4px 0 8px; +} +.health-detail-row { + display: flex; + align-items: baseline; + gap: 12px; + padding: 3px 0; + font-size: 13px; +} +.health-detail-type { min-width: 120px; font-weight: 600; } +.health-detail-msg { flex: 1; } +.health-detail-ts { white-space: nowrap; font-size: 12px; } + /* === Audit-Spur (Phase 5) === */ .modal.modal-large { max-width: 720px; diff --git a/src/static/dashboard.html b/src/static/dashboard.html index aef3a70..3e0a54b 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -6,7 +6,7 @@ AegisSight Monitor-Verwaltung - +