Promote develop → main (2026-07-25 22:22 UTC) #16
@@ -43,6 +43,7 @@ src/:
|
|||||||
database.py: "DB-Connection-Pool, Schema-Helper"
|
database.py: "DB-Connection-Pool, Schema-Helper"
|
||||||
models.py: "Pydantic Request/Response-Schemas"
|
models.py: "Pydantic Request/Response-Schemas"
|
||||||
audit.py: "log_action, get_client_ip, row_to_dict, /api/audit Router"
|
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/:
|
routers/:
|
||||||
organizations.py: "CRUD Mandanten (organizations + Org-Settings + Token-Budget)"
|
organizations.py: "CRUD Mandanten (organizations + Org-Settings + Token-Budget)"
|
||||||
|
|||||||
47
src/health_run.py
Normale Datei
47
src/health_run.py
Normale Datei
@@ -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
|
||||||
@@ -15,6 +15,7 @@ from auth import get_current_admin
|
|||||||
from database import db_dependency, get_db
|
from database import db_dependency, get_db
|
||||||
from audit import log_action, get_client_ip
|
from audit import log_action, get_client_ip
|
||||||
from source_meta import get_meta
|
from source_meta import get_meta
|
||||||
|
from health_run import new_state, try_start, mark_finished, mark_failed
|
||||||
from config import DB_PATH
|
from config import DB_PATH
|
||||||
from shared.source_rules import (
|
from shared.source_rules import (
|
||||||
discover_source,
|
discover_source,
|
||||||
@@ -847,6 +848,52 @@ async def get_health_history(
|
|||||||
return [dict(row) for row in await cursor.fetchall()]
|
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")
|
@router.get("/{source_id}/health")
|
||||||
async def get_source_health(
|
async def get_source_health(
|
||||||
source_id: int,
|
source_id: int,
|
||||||
|
|||||||
@@ -924,6 +924,10 @@ input[type="date"].filter-select { padding: 6px 10px; }
|
|||||||
.health-badge-btn { cursor: pointer; }
|
.health-badge-btn { cursor: pointer; }
|
||||||
.health-badge-btn:hover { filter: brightness(1.25); text-decoration: underline; }
|
.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" */
|
/* Checkbox-Spalte: nur sichtbar bei Herkunfts-Filter "Kundenquellen" */
|
||||||
.select-col { display: none; }
|
.select-col { display: none; }
|
||||||
table.show-select .select-col { display: table-cell; }
|
table.show-select .select-col { display: table-cell; }
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>AegisSight Monitor-Verwaltung</title>
|
<title>AegisSight Monitor-Verwaltung</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||||
<link rel="apple-touch-icon" href="/static/favicon.svg">
|
<link rel="apple-touch-icon" href="/static/favicon.svg">
|
||||||
<link rel="stylesheet" href="/static/css/style.css?v=20260725i">
|
<link rel="stylesheet" href="/static/css/style.css?v=20260725j">
|
||||||
<script>(function(){var t=localStorage.getItem('portal_theme');if(t==='light')document.documentElement.setAttribute('data-theme','light');try{var a=JSON.parse(localStorage.getItem('osint_a11y')||'{}');Object.keys(a).forEach(function(k){if(a[k])document.documentElement.setAttribute('data-a11y-'+k,'true');});}catch(e){}})()</script>
|
<script>(function(){var t=localStorage.getItem('portal_theme');if(t==='light')document.documentElement.setAttribute('data-theme','light');try{var a=JSON.parse(localStorage.getItem('osint_a11y')||'{}');Object.keys(a).forEach(function(k){if(a[k])document.documentElement.setAttribute('data-a11y-'+k,'true');});}catch(e){}})()</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -1006,7 +1006,7 @@
|
|||||||
|
|
||||||
<script src="/static/js/a11y.js?v=20260725a"></script>
|
<script src="/static/js/a11y.js?v=20260725a"></script>
|
||||||
<script src="/static/js/app.js?v=20260725s"></script>
|
<script src="/static/js/app.js?v=20260725s"></script>
|
||||||
<script src="/static/js/sources.js?v=20260725c"></script>
|
<script src="/static/js/sources.js?v=20260725d"></script>
|
||||||
<script src="/static/js/aufgaben.js?v=20260725a"></script>
|
<script src="/static/js/aufgaben.js?v=20260725a"></script>
|
||||||
<script src="/static/js/x-scraper.js?v=20260522a"></script>
|
<script src="/static/js/x-scraper.js?v=20260522a"></script>
|
||||||
<script src="/static/js/audit.js?v=20260509d"></script>
|
<script src="/static/js/audit.js?v=20260509d"></script>
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ async function loadUnifiedSources() {
|
|||||||
}
|
}
|
||||||
renderUnifiedStats(stats);
|
renderUnifiedStats(stats);
|
||||||
filterUnifiedSources();
|
filterUnifiedSources();
|
||||||
|
checkHealthRunOnLoad();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Quellenliste laden fehlgeschlagen:", err);
|
console.error("Quellenliste laden fehlgeschlagen:", err);
|
||||||
}
|
}
|
||||||
@@ -194,7 +195,75 @@ function renderUnifiedStats(stats) {
|
|||||||
if (h.warnings) parts.push(`<span class="sources-stat-item health-warning"><span class="sources-stat-value">${h.warnings}</span> Warnungen</span>`);
|
if (h.warnings) parts.push(`<span class="sources-stat-item health-warning"><span class="sources-stat-value">${h.warnings}</span> Warnungen</span>`);
|
||||||
if (h.ok) parts.push(`<span class="sources-stat-item health-ok"><span class="sources-stat-value">${h.ok}</span> OK</span>`);
|
if (h.ok) parts.push(`<span class="sources-stat-item health-ok"><span class="sources-stat-value">${h.ok}</span> OK</span>`);
|
||||||
|
|
||||||
|
const lastCheck = stats.last_check ? formatDateTime(stats.last_check) : "noch nie";
|
||||||
|
parts.push(`<span class="sources-stat-item health-run-item"><button class="btn btn-secondary btn-small" id="runHealthCheckBtn" onclick="startHealthRun()">Jetzt prüfen</button><span class="text-secondary" id="healthRunInfo">Zuletzt geprüft ${lastCheck}</span></span>`);
|
||||||
|
|
||||||
bar.innerHTML = parts.join("");
|
bar.innerHTML = parts.join("");
|
||||||
|
if (healthRunPollTimer) setHealthRunButton(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Manueller Health-Run (Knopf in der Stats-Leiste) ---
|
||||||
|
let healthRunPollTimer = null;
|
||||||
|
|
||||||
|
async function startHealthRun() {
|
||||||
|
try {
|
||||||
|
await API.post("/api/sources/health/run", {});
|
||||||
|
setHealthRunButton(true);
|
||||||
|
beginHealthRunPolling();
|
||||||
|
showToast("Prüfung gestartet. Das kann einige Minuten dauern.", "info");
|
||||||
|
} catch (err) {
|
||||||
|
if (String(err.message || "").includes("bereits")) {
|
||||||
|
showToast("Es läuft bereits eine Prüfung.", "warning");
|
||||||
|
setHealthRunButton(true);
|
||||||
|
beginHealthRunPolling();
|
||||||
|
} else {
|
||||||
|
showToast("Start fehlgeschlagen. " + err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginHealthRunPolling() {
|
||||||
|
if (healthRunPollTimer) return;
|
||||||
|
healthRunPollTimer = setInterval(pollHealthRunStatus, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollHealthRunStatus() {
|
||||||
|
try {
|
||||||
|
const st = await API.get("/api/sources/health/run-status");
|
||||||
|
if (st.running) { setHealthRunButton(true); return; }
|
||||||
|
// Lauf beendet
|
||||||
|
if (healthRunPollTimer) { clearInterval(healthRunPollTimer); healthRunPollTimer = null; }
|
||||||
|
setHealthRunButton(false);
|
||||||
|
if (st.result) {
|
||||||
|
const r = st.result;
|
||||||
|
showToast(`Health-Check abgeschlossen. ${r.checked} geprüft, ${r.issues} Probleme, ${r.suggestions} neue Vorschläge.`, "success");
|
||||||
|
} else if (st.error) {
|
||||||
|
showToast("Health-Check fehlgeschlagen. " + st.error, "error");
|
||||||
|
} else {
|
||||||
|
showToast("Prüfung beendet, Ergebnis unbekannt (Server wurde neu gestartet).", "info");
|
||||||
|
}
|
||||||
|
loadUnifiedSources();
|
||||||
|
if (typeof refreshTasksBadge === "function") refreshTasksBadge();
|
||||||
|
} catch (_) { /* nächster Tick versucht es erneut */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHealthRunButton(running) {
|
||||||
|
const btn = document.getElementById("runHealthCheckBtn");
|
||||||
|
if (!btn) return;
|
||||||
|
btn.disabled = running;
|
||||||
|
btn.textContent = running ? "Prüfung läuft…" : "Jetzt prüfen";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beim Öffnen des Quellen-Reiters einmal nachsehen, ob (z.B. aus einer anderen
|
||||||
|
// Session) gerade ein Lauf aktiv ist, und dann das Polling aufnehmen.
|
||||||
|
async function checkHealthRunOnLoad() {
|
||||||
|
try {
|
||||||
|
const st = await API.get("/api/sources/health/run-status");
|
||||||
|
if (st.running) {
|
||||||
|
setHealthRunButton(true);
|
||||||
|
beginHealthRunPolling();
|
||||||
|
}
|
||||||
|
} catch (_) { /* unkritisch */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderUnifiedSources(sources) {
|
function renderUnifiedSources(sources) {
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ AUTH_PROTECTED = [
|
|||||||
("GET", "/api/sources/health"),
|
("GET", "/api/sources/health"),
|
||||||
("GET", "/api/sources/suggestions"),
|
("GET", "/api/sources/suggestions"),
|
||||||
("PUT", "/api/sources/suggestions/1"),
|
("PUT", "/api/sources/suggestions/1"),
|
||||||
|
("POST", "/api/sources/health/run"),
|
||||||
|
("GET", "/api/sources/health/run-status"),
|
||||||
("POST", "/api/sources/health/search-fix/1"),
|
("POST", "/api/sources/health/search-fix/1"),
|
||||||
("GET", "/api/statistik"),
|
("GET", "/api/statistik"),
|
||||||
("GET", "/api/token-usage/overview"),
|
("GET", "/api/token-usage/overview"),
|
||||||
|
|||||||
50
tests/test_health_run.py
Normale Datei
50
tests/test_health_run.py
Normale Datei
@@ -0,0 +1,50 @@
|
|||||||
|
"""Tests für src/health_run.py - Zustandsübergänge des manuellen Health-Runs."""
|
||||||
|
from health_run import new_state, try_start, mark_finished, mark_failed
|
||||||
|
|
||||||
|
|
||||||
|
def test_initial_state_is_idle():
|
||||||
|
s = new_state()
|
||||||
|
assert s["running"] is False
|
||||||
|
assert s["started_at"] is None
|
||||||
|
assert s["finished_at"] is None
|
||||||
|
assert s["result"] is None
|
||||||
|
assert s["error"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_try_start_sets_running_and_resets():
|
||||||
|
s = new_state()
|
||||||
|
s["result"] = {"checked": 1}
|
||||||
|
s["error"] = "alt"
|
||||||
|
assert try_start(s, now="2026-07-25T12:00:00") is True
|
||||||
|
assert s["running"] is True
|
||||||
|
assert s["started_at"] == "2026-07-25T12:00:00"
|
||||||
|
assert s["finished_at"] is None
|
||||||
|
assert s["result"] is None
|
||||||
|
assert s["error"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_double_start_blocked():
|
||||||
|
s = new_state()
|
||||||
|
assert try_start(s) is True
|
||||||
|
assert try_start(s) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_finished_allows_restart():
|
||||||
|
s = new_state()
|
||||||
|
try_start(s, now="2026-07-25T12:00:00")
|
||||||
|
mark_finished(s, {"checked": 430, "issues": 110, "suggestions": 3}, now="2026-07-25T12:05:00")
|
||||||
|
assert s["running"] is False
|
||||||
|
assert s["finished_at"] == "2026-07-25T12:05:00"
|
||||||
|
assert s["result"]["checked"] == 430
|
||||||
|
assert s["error"] is None
|
||||||
|
assert try_start(s) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_failed_sets_error():
|
||||||
|
s = new_state()
|
||||||
|
try_start(s)
|
||||||
|
mark_failed(s, "Verbindung weg", now="2026-07-25T12:01:00")
|
||||||
|
assert s["running"] is False
|
||||||
|
assert s["error"] == "Verbindung weg"
|
||||||
|
assert s["result"] is None
|
||||||
|
assert try_start(s) is True
|
||||||
In neuem Issue referenzieren
Einen Benutzer sperren