Promote develop -> main (Quellen-Health Schritt 1: Sub-Section + Pagination + Backend-Slim + UX) #1
@@ -565,17 +565,46 @@ async def add_discovered_sources(
|
|||||||
|
|
||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
async def get_health(
|
async def get_health(
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
admin: dict = Depends(get_current_admin),
|
admin: dict = Depends(get_current_admin),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
"""Health-Check-Ergebnisse abrufen."""
|
"""Health-Check-Ergebnisse abrufen.
|
||||||
|
|
||||||
|
Default-Limit 100, sortiert nach Status (errors first, dann warnings, dann ok).
|
||||||
|
Counters (errors/warnings/ok/total_checks) beziehen sich auf den GESAMTEN
|
||||||
|
Datenbestand, nicht nur auf die zurückgegebene Page. Damit kann das Frontend
|
||||||
|
den vollen Status anzeigen, ohne alle Zeilen rendern zu müssen.
|
||||||
|
has_more zeigt an, ob es weitere Items zum Nachladen gibt.
|
||||||
|
all_orgs liefert die Liste aller Tenants mit Health-Checks (für Filter-Dropdown).
|
||||||
|
"""
|
||||||
|
limit = max(1, min(int(limit or 100), 5000))
|
||||||
|
offset = max(0, int(offset or 0))
|
||||||
|
|
||||||
# Prüfen ob Tabelle existiert
|
# Prüfen ob Tabelle existiert
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks'"
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks'"
|
||||||
)
|
)
|
||||||
if not await cursor.fetchone():
|
if not await cursor.fetchone():
|
||||||
return {"last_check": None, "total_checks": 0, "errors": 0, "warnings": 0, "ok": 0, "checks": []}
|
return {
|
||||||
|
"last_check": None, "total_checks": 0,
|
||||||
|
"errors": 0, "warnings": 0, "ok": 0,
|
||||||
|
"checks": [], "all_orgs": [],
|
||||||
|
"limit": limit, "offset": offset, "has_more": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Aggregate über GESAMTEN Bestand (eine GROUP-BY-Query, billig)
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT status, COUNT(*) AS n FROM source_health_checks GROUP BY status"
|
||||||
|
)
|
||||||
|
counts = {row["status"]: row["n"] for row in await cursor.fetchall()}
|
||||||
|
error_count = counts.get("error", 0)
|
||||||
|
warning_count = counts.get("warning", 0)
|
||||||
|
ok_count = counts.get("ok", 0)
|
||||||
|
total_checks = error_count + warning_count + ok_count
|
||||||
|
|
||||||
|
# Paginierte Daten
|
||||||
cursor = await db.execute("""
|
cursor = await db.execute("""
|
||||||
SELECT
|
SELECT
|
||||||
h.source_id, s.name, s.domain, s.tenant_id, s.language,
|
h.source_id, s.name, s.domain, s.tenant_id, s.language,
|
||||||
@@ -587,12 +616,20 @@ async def get_health(
|
|||||||
ORDER BY
|
ORDER BY
|
||||||
CASE h.status WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
|
CASE h.status WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
|
||||||
s.name
|
s.name
|
||||||
""")
|
LIMIT ? OFFSET ?
|
||||||
|
""", (limit, offset))
|
||||||
checks = [dict(row) for row in await cursor.fetchall()]
|
checks = [dict(row) for row in await cursor.fetchall()]
|
||||||
|
|
||||||
error_count = sum(1 for c in checks if c["status"] == "error")
|
# Org-Liste (alle Tenants mit Health-Checks, für Frontend-Filter-Dropdown)
|
||||||
warning_count = sum(1 for c in checks if c["status"] == "warning")
|
cursor = await db.execute("""
|
||||||
ok_count = sum(1 for c in checks if c["status"] == "ok")
|
SELECT DISTINCT s.tenant_id AS id, o.name AS name
|
||||||
|
FROM source_health_checks h
|
||||||
|
JOIN sources s ON s.id = h.source_id
|
||||||
|
LEFT JOIN organizations o ON o.id = s.tenant_id
|
||||||
|
WHERE s.tenant_id IS NOT NULL
|
||||||
|
ORDER BY o.name
|
||||||
|
""")
|
||||||
|
all_orgs = [dict(row) for row in await cursor.fetchall()]
|
||||||
|
|
||||||
cursor = await db.execute("SELECT MAX(checked_at) as last_check FROM source_health_checks")
|
cursor = await db.execute("SELECT MAX(checked_at) as last_check FROM source_health_checks")
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
@@ -600,11 +637,15 @@ async def get_health(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"last_check": last_check,
|
"last_check": last_check,
|
||||||
"total_checks": len(checks),
|
"total_checks": total_checks,
|
||||||
"errors": error_count,
|
"errors": error_count,
|
||||||
"warnings": warning_count,
|
"warnings": warning_count,
|
||||||
"ok": ok_count,
|
"ok": ok_count,
|
||||||
"checks": checks,
|
"checks": checks,
|
||||||
|
"all_orgs": all_orgs,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"has_more": (offset + len(checks)) < total_checks,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -708,7 +708,7 @@
|
|||||||
|
|
||||||
<script src="/static/js/app.js?v=20260509d"></script>
|
<script src="/static/js/app.js?v=20260509d"></script>
|
||||||
<script src="/static/js/sources.js?v=20260509d"></script>
|
<script src="/static/js/sources.js?v=20260509d"></script>
|
||||||
<script src="/static/js/source-health.js?v=20260509e"></script>
|
<script src="/static/js/source-health.js?v=20260509f"></script>
|
||||||
<script src="/static/js/audit.js?v=20260509d"></script>
|
<script src="/static/js/audit.js?v=20260509d"></script>
|
||||||
<div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div>
|
<div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ let healthHistoryCache = [];
|
|||||||
|
|
||||||
// 60-Sekunden-Cache, damit Tab-Wechsel nicht jedes Mal die volle Antwort neu lädt.
|
// 60-Sekunden-Cache, damit Tab-Wechsel nicht jedes Mal die volle Antwort neu lädt.
|
||||||
// Bei Mutationen (Vorschlag annehmen/ablehnen, run-stream, search-fix) wird mit force=true neu geladen.
|
// Bei Mutationen (Vorschlag annehmen/ablehnen, run-stream, search-fix) wird mit force=true neu geladen.
|
||||||
let healthDataCache = { health: null, suggestions: null, history: null, ts: 0 };
|
// Cache-Key beinhaltet das aktuelle Limit, damit "Mehr laden" nicht aus altem Cache bedient wird.
|
||||||
|
let healthDataCache = { health: null, suggestions: null, history: null, ts: 0, limit: 0 };
|
||||||
const HEALTH_CACHE_TTL_MS = 60000;
|
const HEALTH_CACHE_TTL_MS = 60000;
|
||||||
|
|
||||||
|
// Default-Pagination: 100 Items reichen meistens (errors+warnings stehen vorne, ok-Status hinten).
|
||||||
|
// Wird durch loadMoreHealth() / loadAllHealth() hochgesetzt.
|
||||||
|
let healthLoadLimit = 100;
|
||||||
|
|
||||||
|
|
||||||
const CHECK_TYPE_LABELS = {
|
const CHECK_TYPE_LABELS = {
|
||||||
reachability: "Erreichbarkeit",
|
reachability: "Erreichbarkeit",
|
||||||
@@ -45,7 +50,10 @@ document.addEventListener("DOMContentLoaded", setupHealthTab);
|
|||||||
// --- Health-Daten laden ---
|
// --- Health-Daten laden ---
|
||||||
async function loadHealthData(force = false) {
|
async function loadHealthData(force = false) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!force && healthDataCache.health && (now - healthDataCache.ts) < HEALTH_CACHE_TTL_MS) {
|
if (!force
|
||||||
|
&& healthDataCache.health
|
||||||
|
&& healthDataCache.limit === healthLoadLimit
|
||||||
|
&& (now - healthDataCache.ts) < HEALTH_CACHE_TTL_MS) {
|
||||||
healthData = healthDataCache.health;
|
healthData = healthDataCache.health;
|
||||||
suggestionsCache = healthDataCache.suggestions;
|
suggestionsCache = healthDataCache.suggestions;
|
||||||
healthHistoryCache = healthDataCache.history;
|
healthHistoryCache = healthDataCache.history;
|
||||||
@@ -54,14 +62,14 @@ async function loadHealthData(force = false) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const [health, suggestions, history] = await Promise.all([
|
const [health, suggestions, history] = await Promise.all([
|
||||||
API.get("/api/sources/health"),
|
API.get("/api/sources/health?limit=" + healthLoadLimit),
|
||||||
API.get("/api/sources/suggestions"),
|
API.get("/api/sources/suggestions"),
|
||||||
API.get("/api/sources/health/history?limit=10").catch(() => []),
|
API.get("/api/sources/health/history?limit=10").catch(() => []),
|
||||||
]);
|
]);
|
||||||
healthData = health;
|
healthData = health;
|
||||||
suggestionsCache = suggestions;
|
suggestionsCache = suggestions;
|
||||||
healthHistoryCache = history || [];
|
healthHistoryCache = history || [];
|
||||||
healthDataCache = { health, suggestions, history: history || [], ts: Date.now() };
|
healthDataCache = { health, suggestions, history: history || [], ts: Date.now(), limit: healthLoadLimit };
|
||||||
renderHealthDashboard();
|
renderHealthDashboard();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Health-Daten laden fehlgeschlagen:", err);
|
console.error("Health-Daten laden fehlgeschlagen:", err);
|
||||||
@@ -70,6 +78,17 @@ async function loadHealthData(force = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pagination-Steuerung: hochsetzen + neu laden
|
||||||
|
function loadMoreHealth() {
|
||||||
|
healthLoadLimit += 200;
|
||||||
|
loadHealthData(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAllHealth() {
|
||||||
|
healthLoadLimit = 100000;
|
||||||
|
loadHealthData(true);
|
||||||
|
}
|
||||||
|
|
||||||
function applyHealthFilter(checks) {
|
function applyHealthFilter(checks) {
|
||||||
return checks.filter(c => {
|
return checks.filter(c => {
|
||||||
if (healthFilters.status && c.status !== healthFilters.status) return false;
|
if (healthFilters.status && c.status !== healthFilters.status) return false;
|
||||||
@@ -179,14 +198,13 @@ function renderHealthDashboard() {
|
|||||||
// Filter anwenden
|
// Filter anwenden
|
||||||
const allChecks = healthData.checks;
|
const allChecks = healthData.checks;
|
||||||
const filtered = applyHealthFilter(allChecks);
|
const filtered = applyHealthFilter(allChecks);
|
||||||
const okCount = healthData.checks.filter((c) => c.status === "ok").length;
|
// Counters aus Backend-Aggregat (über Gesamt-Bestand, nicht nur Page)
|
||||||
|
const okCount = healthData.ok != null ? healthData.ok : healthData.checks.filter((c) => c.status === "ok").length;
|
||||||
|
const totalAll = healthData.total_checks != null ? healthData.total_checks : allChecks.length;
|
||||||
|
const hasMore = !!healthData.has_more;
|
||||||
|
|
||||||
// Org-Liste fuer Dropdown
|
// Org-Liste aus Backend-Liste (volle Liste, auch wenn Page kleiner ist)
|
||||||
const orgs = Array.from(new Set(allChecks.map(c => c.tenant_id).filter(t => t != null)))
|
const orgs = (healthData.all_orgs || []).map(o => ({ id: String(o.id), name: o.name || ("Org " + o.id) }));
|
||||||
.map(tid => {
|
|
||||||
const c = allChecks.find(x => x.tenant_id === tid);
|
|
||||||
return { id: String(tid), name: c ? c.org_name : `Org ${tid}` };
|
|
||||||
});
|
|
||||||
|
|
||||||
const checkTypes = Array.from(new Set(allChecks.map(c => c.check_type)));
|
const checkTypes = Array.from(new Set(allChecks.map(c => c.check_type)));
|
||||||
|
|
||||||
@@ -219,7 +237,9 @@ function renderHealthDashboard() {
|
|||||||
<option value="global" ${healthFilters.org === "global" ? "selected" : ""}>Nur Grundquellen</option>
|
<option value="global" ${healthFilters.org === "global" ? "selected" : ""}>Nur Grundquellen</option>
|
||||||
${orgs.map(o => `<option value="${esc(o.id)}" ${healthFilters.org === o.id ? "selected" : ""}>Org: ${esc(o.name)}</option>`).join("")}
|
${orgs.map(o => `<option value="${esc(o.id)}" ${healthFilters.org === o.id ? "selected" : ""}>Org: ${esc(o.name)}</option>`).join("")}
|
||||||
</select>
|
</select>
|
||||||
<span class="text-secondary" style="font-size:13px;">${filtered.length} / ${allChecks.length} Ergebnisse</span>
|
<span class="text-secondary" style="font-size:13px;">
|
||||||
|
${filtered.length} / ${allChecks.length} angezeigt${totalAll > allChecks.length ? ` (von ${totalAll} insgesamt)` : ''}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
@@ -252,6 +272,18 @@ function renderHealthDashboard() {
|
|||||||
} else {
|
} else {
|
||||||
healthHtml += '<div class="card-body text-muted">Keine Ergebnisse mit diesen Filtern.</div>';
|
healthHtml += '<div class="card-body text-muted">Keine Ergebnisse mit diesen Filtern.</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Footer mit Mehr-laden-Buttons, falls Backend has_more meldet
|
||||||
|
if (hasMore) {
|
||||||
|
const remaining = Math.max(0, totalAll - allChecks.length);
|
||||||
|
healthHtml += `
|
||||||
|
<div class="card-body" style="display:flex;justify-content:center;gap:10px;align-items:center;border-top:1px solid var(--border, rgba(255,255,255,0.08));">
|
||||||
|
<span class="text-secondary" style="font-size:13px;">${allChecks.length} von ${totalAll} geladen</span>
|
||||||
|
<button class="btn btn-secondary btn-small" onclick="loadMoreHealth()">+200 laden</button>
|
||||||
|
<button class="btn btn-secondary btn-small" onclick="loadAllHealth()">Alle ${remaining} weiteren laden</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
healthHtml += "</div>";
|
healthHtml += "</div>";
|
||||||
} else {
|
} else {
|
||||||
healthHtml = `
|
healthHtml = `
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren