From 14e766173cde543a257c4999b3f7f56350ace1cf Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 25 Jul 2026 18:14:14 +0000 Subject: [PATCH] feat(sources): Knopf Jetzt pruefen startet den Health-Check aus dem Portal - POST /api/sources/health/run (202, bei laufendem Run 409) stoesst den Lauf als BackgroundTask an: eigene DB-Connection, run_health_checks + generate_suggestions aus den geteilten Diensten, exakt nach dem Vorbild des Monitor-Nachtjobs daily_source_health_check. Kein Code-Duplikat. - GET /api/sources/health/run-status liefert den In-Memory-Status (health_run.py, pure Funktionen mit Doppelstart-Sperre, unit-getestet). Nach einem Neustart ist der Status idle, ein partieller Stand wird vom naechsten Lauf archiviert und ueberschrieben (wie beim Nachtjob). - Frontend: Knopf Jetzt pruefen + Zuletzt geprueft in der Stats-Leiste der Quellenliste, Polling alle 4 s, Abschluss-Toast mit geprueft/ Probleme/neue Vorschlaege, danach Liste + Aufgaben-Badge neu laden. Beim Oeffnen des Reiters wird ein laufender Run aus anderer Session erkannt und weiterverfolgt. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 1 + src/health_run.py | 47 ++++++++++++++++++++++++++ src/routers/sources.py | 47 ++++++++++++++++++++++++++ src/static/css/style.css | 4 +++ src/static/dashboard.html | 4 +-- src/static/js/sources.js | 69 +++++++++++++++++++++++++++++++++++++++ tests/test_api_smoke.py | 2 ++ tests/test_health_run.py | 50 ++++++++++++++++++++++++++++ 8 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 src/health_run.py create mode 100644 tests/test_health_run.py diff --git a/CLAUDE.md b/CLAUDE.md index 3350c42..46dd724 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,7 @@ src/: database.py: "DB-Connection-Pool, Schema-Helper" models.py: "Pydantic Request/Response-Schemas" audit.py: "log_action, get_client_ip, row_to_dict, /api/audit Router" + health_run.py: "In-Memory-Status des manuellen Health-Runs (pure Funktionen, unit-testbar). Der Knopf Jetzt prüfen ruft die geteilten Dienste source_health + source_suggester auf (POST /api/sources/health/run, Polling über GET /health/run-status). Zusätzlich zum Monitor-Nachtjob um 04:00. Status ist nach Neustart idle, partieller Stand heilt beim nächsten Lauf" routers/: organizations.py: "CRUD Mandanten (organizations + Org-Settings + Token-Budget)" diff --git a/src/health_run.py b/src/health_run.py new file mode 100644 index 0000000..3803f15 --- /dev/null +++ b/src/health_run.py @@ -0,0 +1,47 @@ +"""In-Memory-Status für den manuellen Health-Run des Portals. + +Pure Funktionen ohne DB und IO, damit die Zustandsübergänge unit-testbar sind. +Der Status lebt nur im Prozess. Nach einem Neustart ist er wieder idle, ein +dabei abgebrochener Lauf hinterlässt höchstens einen partiellen Stand in +source_health_checks, den der nächste Lauf archiviert und überschreibt +(identisch zum Verhalten des Monitor-Nachtjobs um 04:00 Uhr). +""" +from datetime import datetime + + +def new_state() -> dict: + return { + "running": False, + "started_at": None, + "finished_at": None, + "result": None, + "error": None, + } + + +def _now_iso(now=None) -> str: + return now if now is not None else datetime.now().isoformat(timespec="seconds") + + +def try_start(state: dict, now=None) -> bool: + """Setzt den Lauf auf running. False, wenn schon einer läuft.""" + if state.get("running"): + return False + state["running"] = True + state["started_at"] = _now_iso(now) + state["finished_at"] = None + state["result"] = None + state["error"] = None + return True + + +def mark_finished(state: dict, result: dict, now=None) -> None: + state["running"] = False + state["finished_at"] = _now_iso(now) + state["result"] = result + + +def mark_failed(state: dict, error: str, now=None) -> None: + state["running"] = False + state["finished_at"] = _now_iso(now) + state["error"] = error diff --git a/src/routers/sources.py b/src/routers/sources.py index afd5b7a..bafcf4b 100644 --- a/src/routers/sources.py +++ b/src/routers/sources.py @@ -15,6 +15,7 @@ from auth import get_current_admin from database import db_dependency, get_db from audit import log_action, get_client_ip from source_meta import get_meta +from health_run import new_state, try_start, mark_finished, mark_failed from config import DB_PATH from shared.source_rules import ( discover_source, @@ -847,6 +848,52 @@ async def get_health_history( return [dict(row) for row in await cursor.fetchall()] +# Status des manuellen Health-Runs. In-Memory: nach einem Neustart wieder idle, +# ein dabei abgebrochener Lauf hinterlässt höchstens einen partiellen +# source_health_checks-Stand, den der nächste Lauf archiviert und heilt. +_HEALTH_RUN_STATE = new_state() + + +async def _health_run_background(): + """Manueller Health-Run, exakt nach dem Vorbild des Monitor-Nachtjobs + daily_source_health_check (eigene Connection, Checks + Vorschläge).""" + from shared.services.source_health import run_health_checks + from shared.services.source_suggester import generate_suggestions + + db = await get_db() + try: + result = await run_health_checks(db) + suggestions = await generate_suggestions(db) + mark_finished(_HEALTH_RUN_STATE, {**result, "suggestions": suggestions}) + logger.info( + "Manueller Health-Run: %s geprüft, %s Probleme, %s neue Vorschläge", + result.get("checked"), result.get("issues"), suggestions, + ) + except Exception as e: + logger.error("Manueller Health-Run fehlgeschlagen: %s", e, exc_info=True) + mark_failed(_HEALTH_RUN_STATE, str(e)) + finally: + await db.close() + + +@router.post("/health/run", status_code=202) +async def start_health_run( + background_tasks: BackgroundTasks, + admin: dict = Depends(get_current_admin), +): + """Health-Check + Vorschläge manuell anstoßen (läuft im Hintergrund weiter).""" + if not try_start(_HEALTH_RUN_STATE): + raise HTTPException(status_code=409, detail="Es läuft bereits eine Prüfung") + background_tasks.add_task(_health_run_background) + return {"status": "started", "started_at": _HEALTH_RUN_STATE["started_at"]} + + +@router.get("/health/run-status") +async def health_run_status(admin: dict = Depends(get_current_admin)): + """Aktueller Zustand des manuellen Health-Runs (Polling-Ziel des Frontends).""" + return _HEALTH_RUN_STATE + + @router.get("/{source_id}/health") async def get_source_health( source_id: int, diff --git a/src/static/css/style.css b/src/static/css/style.css index 20b0eb7..db7b9fc 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -924,6 +924,10 @@ input[type="date"].filter-select { padding: 6px 10px; } .health-badge-btn { cursor: pointer; } .health-badge-btn:hover { filter: brightness(1.25); text-decoration: underline; } +/* "Jetzt pruefen"-Knopf in der Stats-Leiste */ +.health-run-item { display: inline-flex; align-items: center; gap: 8px; } +.health-run-item #healthRunInfo { font-size: 12px; } + /* Checkbox-Spalte: nur sichtbar bei Herkunfts-Filter "Kundenquellen" */ .select-col { display: none; } table.show-select .select-col { display: table-cell; } diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 92e3ba3..5f80542 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -6,7 +6,7 @@ AegisSight Monitor-Verwaltung - +