Promote develop -> main
This commit was merged in pull request #1.
Dieser Commit ist enthalten in:
@@ -565,35 +565,82 @@ async def add_discovered_sources(
|
||||
|
||||
@router.get("/health")
|
||||
async def get_health(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
admin: dict = Depends(get_current_admin),
|
||||
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
|
||||
cursor = await db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks'"
|
||||
)
|
||||
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 nach (check_type, status)
|
||||
# liefert sowohl die Top-Counters als auch das feine Breakdown für die UI.
|
||||
cursor = await db.execute(
|
||||
"SELECT check_type, status, COUNT(*) AS n FROM source_health_checks GROUP BY check_type, status"
|
||||
)
|
||||
breakdown = {} # {check_type: {status: count}}
|
||||
error_count = 0
|
||||
warning_count = 0
|
||||
ok_count = 0
|
||||
for row in await cursor.fetchall():
|
||||
ct = row["check_type"]
|
||||
st = row["status"]
|
||||
breakdown.setdefault(ct, {})[st] = row["n"]
|
||||
if st == "error":
|
||||
error_count += row["n"]
|
||||
elif st == "warning":
|
||||
warning_count += row["n"]
|
||||
elif st == "ok":
|
||||
ok_count += row["n"]
|
||||
total_checks = error_count + warning_count + ok_count
|
||||
|
||||
# Paginierte Daten
|
||||
cursor = await db.execute("""
|
||||
SELECT
|
||||
h.id, h.source_id, s.name, s.domain, s.url, s.source_type,
|
||||
s.tenant_id, s.category, s.language, s.bias,
|
||||
h.source_id, s.name, s.domain, s.tenant_id, s.language,
|
||||
o.name AS org_name,
|
||||
h.check_type, h.status, h.message, h.details, h.checked_at
|
||||
h.check_type, h.status, h.message
|
||||
FROM source_health_checks h
|
||||
JOIN sources s ON s.id = h.source_id
|
||||
LEFT JOIN organizations o ON o.id = s.tenant_id
|
||||
ORDER BY
|
||||
CASE h.status WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
|
||||
s.name
|
||||
""")
|
||||
LIMIT ? OFFSET ?
|
||||
""", (limit, offset))
|
||||
checks = [dict(row) for row in await cursor.fetchall()]
|
||||
|
||||
error_count = sum(1 for c in checks if c["status"] == "error")
|
||||
warning_count = sum(1 for c in checks if c["status"] == "warning")
|
||||
ok_count = sum(1 for c in checks if c["status"] == "ok")
|
||||
# Org-Liste (alle Tenants mit Health-Checks, für Frontend-Filter-Dropdown)
|
||||
cursor = await db.execute("""
|
||||
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")
|
||||
row = await cursor.fetchone()
|
||||
@@ -601,11 +648,16 @@ async def get_health(
|
||||
|
||||
return {
|
||||
"last_check": last_check,
|
||||
"total_checks": len(checks),
|
||||
"total_checks": total_checks,
|
||||
"errors": error_count,
|
||||
"warnings": warning_count,
|
||||
"ok": ok_count,
|
||||
"breakdown": breakdown,
|
||||
"checks": checks,
|
||||
"all_orgs": all_orgs,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": (offset + len(checks)) < total_checks,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -393,6 +393,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quellen-Health (Sub-Tab; Inhalt wird von source-health.js dynamisch in #healthContent gerendert) -->
|
||||
<div class="section" id="sub-source-health">
|
||||
<div id="healthContent"></div>
|
||||
</div>
|
||||
|
||||
</div> <!-- /sec-sources -->
|
||||
|
||||
<!-- Audit-Log Section -->
|
||||
<div class="section" id="sec-audit">
|
||||
<div class="action-bar" style="flex-wrap:wrap;gap:8px;">
|
||||
@@ -708,7 +715,7 @@
|
||||
|
||||
<script src="/static/js/app.js?v=20260509d"></script>
|
||||
<script src="/static/js/sources.js?v=20260509d"></script>
|
||||
<script src="/static/js/source-health.js?v=20260509d"></script>
|
||||
<script src="/static/js/source-health.js?v=20260509g"></script>
|
||||
<script src="/static/js/audit.js?v=20260509d"></script>
|
||||
<div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div>
|
||||
</body>
|
||||
|
||||
@@ -3,9 +3,21 @@
|
||||
|
||||
let healthData = null;
|
||||
let suggestionsCache = [];
|
||||
let healthFilters = { status: "", check_type: "", org: "all" };
|
||||
// Default-Filter zeigt nur Probleme (errors + warnings); OK ist meistens Rauschen.
|
||||
// "issues" ist ein virtueller Status-Wert, den nur das Frontend versteht (siehe applyHealthFilter).
|
||||
let healthFilters = { status: "issues", check_type: "", org: "all" };
|
||||
let healthHistoryCache = [];
|
||||
|
||||
// 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.
|
||||
// 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;
|
||||
|
||||
// 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 = {
|
||||
reachability: "Erreichbarkeit",
|
||||
@@ -38,16 +50,28 @@ function setupHealthTab() {
|
||||
document.addEventListener("DOMContentLoaded", setupHealthTab);
|
||||
|
||||
// --- Health-Daten laden ---
|
||||
async function loadHealthData() {
|
||||
async function loadHealthData(force = false) {
|
||||
const now = Date.now();
|
||||
if (!force
|
||||
&& healthDataCache.health
|
||||
&& healthDataCache.limit === healthLoadLimit
|
||||
&& (now - healthDataCache.ts) < HEALTH_CACHE_TTL_MS) {
|
||||
healthData = healthDataCache.health;
|
||||
suggestionsCache = healthDataCache.suggestions;
|
||||
healthHistoryCache = healthDataCache.history;
|
||||
renderHealthDashboard();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
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/health/history?limit=10").catch(() => []),
|
||||
]);
|
||||
healthData = health;
|
||||
suggestionsCache = suggestions;
|
||||
healthHistoryCache = history || [];
|
||||
healthDataCache = { health, suggestions, history: history || [], ts: Date.now(), limit: healthLoadLimit };
|
||||
renderHealthDashboard();
|
||||
} catch (err) {
|
||||
console.error("Health-Daten laden fehlgeschlagen:", err);
|
||||
@@ -56,9 +80,22 @@ async function loadHealthData() {
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination-Steuerung: hochsetzen + neu laden
|
||||
function loadMoreHealth() {
|
||||
healthLoadLimit += 200;
|
||||
loadHealthData(true);
|
||||
}
|
||||
|
||||
function loadAllHealth() {
|
||||
healthLoadLimit = 100000;
|
||||
loadHealthData(true);
|
||||
}
|
||||
|
||||
function applyHealthFilter(checks) {
|
||||
return checks.filter(c => {
|
||||
if (healthFilters.status && c.status !== healthFilters.status) return false;
|
||||
// "issues" = Sammelfilter für errors + warnings (Default)
|
||||
if (healthFilters.status === "issues" && c.status === "ok") return false;
|
||||
if (healthFilters.status && healthFilters.status !== "issues" && c.status !== healthFilters.status) return false;
|
||||
if (healthFilters.check_type && c.check_type !== healthFilters.check_type) return false;
|
||||
if (healthFilters.org === "global" && c.tenant_id !== null) return false;
|
||||
if (healthFilters.org !== "all" && healthFilters.org !== "global"
|
||||
@@ -165,17 +202,31 @@ function renderHealthDashboard() {
|
||||
// Filter anwenden
|
||||
const allChecks = healthData.checks;
|
||||
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
|
||||
const orgs = Array.from(new Set(allChecks.map(c => c.tenant_id).filter(t => t != null)))
|
||||
.map(tid => {
|
||||
const c = allChecks.find(x => x.tenant_id === tid);
|
||||
return { id: String(tid), name: c ? c.org_name : `Org ${tid}` };
|
||||
});
|
||||
// Org-Liste aus Backend-Liste (volle Liste, auch wenn Page kleiner ist)
|
||||
const orgs = (healthData.all_orgs || []).map(o => ({ id: String(o.id), name: o.name || ("Org " + o.id) }));
|
||||
|
||||
const checkTypes = Array.from(new Set(allChecks.map(c => c.check_type)));
|
||||
|
||||
// Counter-Aufgliederung aus Backend-Breakdown (pro check_type x status).
|
||||
// Beispiel: { reachability: {ok: 281, error: 3, warning: 1}, feed_validity: {...}, stale: {...}, duplicate: {...} }
|
||||
const breakdown = healthData.breakdown || {};
|
||||
function breakdownLine(statusKey, cssClass) {
|
||||
const entries = Object.entries(breakdown)
|
||||
.map(([ct, byStatus]) => [ct, byStatus[statusKey] || 0])
|
||||
.filter(([_, n]) => n > 0)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
if (entries.length === 0) return "";
|
||||
const total = entries.reduce((s, [, n]) => s + n, 0);
|
||||
const detail = entries.map(([ct, n]) => `${n} ${CHECK_TYPE_LABELS[ct] || ct}`).join(", ");
|
||||
const label = statusKey === "error" ? "Fehler" : (statusKey === "warning" ? "Warnungen" : "OK");
|
||||
return `<span class="${cssClass}" title="${esc(detail)}">${total} ${label}</span> <span class="text-secondary" style="font-size:11px;">(${esc(detail)})</span>`;
|
||||
}
|
||||
|
||||
healthHtml = `
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
@@ -183,14 +234,15 @@ function renderHealthDashboard() {
|
||||
<span class="text-secondary" style="font-size:13px;">
|
||||
Letzter Check: ${healthData.last_check ? formatDateTime(healthData.last_check) : "Noch nie"}
|
||||
|
|
||||
<span class="text-danger">${healthData.errors} Fehler</span>
|
||||
<span class="text-warning">${healthData.warnings} Warnungen</span>
|
||||
${breakdownLine("error", "text-danger") || `<span class="text-danger">0 Fehler</span>`}
|
||||
${breakdownLine("warning", "text-warning") || `<span class="text-warning">0 Warnungen</span>`}
|
||||
<span class="text-success">${okCount} OK</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="action-bar" style="border-bottom:1px solid var(--border, rgba(255,255,255,0.08));">
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
|
||||
<select class="filter-select" onchange="setHealthFilter('status', this.value)">
|
||||
<option value="issues" ${healthFilters.status === "issues" ? "selected" : ""}>Nur Probleme (Default)</option>
|
||||
<option value="" ${healthFilters.status === "" ? "selected" : ""}>Alle Status</option>
|
||||
<option value="error" ${healthFilters.status === "error" ? "selected" : ""}>Nur Fehler</option>
|
||||
<option value="warning" ${healthFilters.status === "warning" ? "selected" : ""}>Nur Warnungen</option>
|
||||
@@ -205,7 +257,9 @@ function renderHealthDashboard() {
|
||||
<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("")}
|
||||
</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>`;
|
||||
|
||||
@@ -235,9 +289,31 @@ function renderHealthDashboard() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
} else if (hasMore) {
|
||||
// 0 Treffer in der Page, aber es gibt noch ungeladene Items.
|
||||
// Hinweis, dass der Filter erst über die volle Liste sicher ist.
|
||||
healthHtml += `
|
||||
<div class="card-body text-muted">
|
||||
Keine Treffer in den geladenen ${allChecks.length} von ${totalAll} Items mit dem aktuellen Filter.
|
||||
<a href="#" onclick="event.preventDefault(); loadAllHealth()" style="text-decoration:underline;">
|
||||
Alle ${totalAll} Health-Checks laden
|
||||
</a> und Filter erneut anwenden.
|
||||
</div>`;
|
||||
} else {
|
||||
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>";
|
||||
} else {
|
||||
healthHtml = `
|
||||
@@ -289,7 +365,7 @@ async function handleSuggestion(id, accept) {
|
||||
if (result.action) {
|
||||
showToast("Ergebnis: " + result.action, "success");
|
||||
}
|
||||
loadHealthData();
|
||||
loadHealthData(true);
|
||||
// Grundquellen-Liste auch aktualisieren
|
||||
if (typeof loadGlobalSources === "function") loadGlobalSources();
|
||||
} catch (err) {
|
||||
@@ -371,7 +447,7 @@ async function runHealthCheck() {
|
||||
}
|
||||
}
|
||||
|
||||
loadHealthData();
|
||||
loadHealthData(true);
|
||||
} catch (err) {
|
||||
progressEl.innerHTML = '<span class="text-danger">Fehler: ' + esc(err.message) + '</span>';
|
||||
} finally {
|
||||
@@ -419,7 +495,7 @@ async function searchFix(btn) {
|
||||
msg += `\n\nKosten: $${result.cost_usd.toFixed(2)}`;
|
||||
}
|
||||
showToast(msg, "info");
|
||||
loadHealthData();
|
||||
loadHealthData(true);
|
||||
} catch (err) {
|
||||
showToast("Fehler: " + err.message, "error");
|
||||
} finally {
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren