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 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-07-25 18:14:14 +00:00
Ursprung bcfdac158e
Commit 14e766173c
8 geänderte Dateien mit 222 neuen und 2 gelöschten Zeilen

47
src/health_run.py Normale Datei
Datei anzeigen

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