Phase 17: Health-Tab Filter + Org-Spalte + History-View + URL-Schema-Fix

Backend:
- shared/services/source_health.py: URL ohne https://-Prefix wird normalisiert
  bevor httpx.get() aufgerufen wird (Bug-Fix: t.me/kanal liess httpx mit
  ValueError crashen, Synchron mit Monitor-Fix 1ee6c4d).
- routers/sources.py /health: Query erweitert um tenant_id, category,
  language, bias, org_name (LEFT JOIN organizations) - Frontend kann jetzt
  pro Issue Tenant-Info anzeigen.
- routers/sources.py /health/history NEU: letzte N Runs aus
  source_health_history aggregiert (run_id, archived_at, errors/warnings/ok).

Frontend (source-health.js):
- healthFilters State: status / check_type / org.
- applyHealthFilter() reduziert die Anzeige.
- Filter-Bar mit 3 Dropdowns + Counter "X / Y Ergebnisse".
- Tabelle erweitert: Org-Spalte ("global" oder Org-Name), Sprache-Spalte.
- History-View neu: letzte 10 Runs als Tabelle (Zeitpunkt, Run-ID, Counts).

Cache-Buster auf 20260509c gebumpt.
Dieser Commit ist enthalten in:
claude-dev
2026-05-09 04:47:05 +00:00
Ursprung 07a426561c
Commit bff934d673
5 geänderte Dateien mit 136 neuen und 16 gelöschten Zeilen

Datei anzeigen

@@ -578,9 +578,12 @@ async def get_health(
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,
o.name AS org_name,
h.check_type, h.status, h.message, h.details, h.checked_at
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
@@ -605,6 +608,40 @@ async def get_health(
}
@router.get("/health/history")
async def get_health_history(
limit: int = 20,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Liefert die letzten N Health-Check-Runs aus source_health_history.
Pro Run: run_id, archived_at (Run-Zeitpunkt), Counts pro Status.
"""
cursor = await db.execute("""
SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_history'
""")
if not await cursor.fetchone():
return []
cursor = await db.execute("""
SELECT run_id,
MIN(archived_at) AS archived_at,
COUNT(*) AS total,
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS errors,
SUM(CASE WHEN status = 'warning' THEN 1 ELSE 0 END) AS warnings,
SUM(CASE WHEN status = 'ok' THEN 1 ELSE 0 END) AS ok
FROM source_health_history
GROUP BY run_id
ORDER BY archived_at DESC
LIMIT ?
""", (max(1, min(limit, 100)),))
return [dict(row) for row in await cursor.fetchall()]
@router.get("/suggestions")
async def get_suggestions(
admin: dict = Depends(get_current_admin),

Datei anzeigen

@@ -112,6 +112,10 @@ async def _check_source_reachability(
checks = []
url = source["url"]
# URL-Schema sicherstellen: t.me-Kanaele und andere Domains koennen ohne https:// vorkommen
if url and not url.startswith(("http://", "https://")):
url = "https://" + url.lstrip("/")
try:
resp = await client.get(url)

Datei anzeigen

@@ -6,7 +6,7 @@
<title>AegisSight Monitor-Verwaltung</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="apple-touch-icon" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/style.css?v=20260509b">
<link rel="stylesheet" href="/static/css/style.css?v=20260509c">
<style>
.source-badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:12px; font-weight:600; }
@@ -697,10 +697,10 @@
</div>
</div>
<script src="/static/js/app.js?v=20260509b"></script>
<script src="/static/js/sources.js?v=20260509b"></script>
<script src="/static/js/source-health.js?v=20260509b"></script>
<script src="/static/js/audit.js?v=20260509b"></script>
<script src="/static/js/app.js?v=20260509c"></script>
<script src="/static/js/sources.js?v=20260509c"></script>
<script src="/static/js/source-health.js?v=20260509c"></script>
<script src="/static/js/audit.js?v=20260509c"></script>
<div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div>
</body>
</html>

Datei anzeigen

@@ -7,7 +7,7 @@
<title>AegisSight Monitor-Verwaltung - Anmeldung</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="apple-touch-icon" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/style.css?v=20260509b">
<link rel="stylesheet" href="/static/css/style.css?v=20260509c">
</head>
<body class="login-page">
<div class="login-container">

Datei anzeigen

@@ -3,6 +3,9 @@
let healthData = null;
let suggestionsCache = [];
let healthFilters = { status: "", check_type: "", org: "all" };
let healthHistoryCache = [];
const CHECK_TYPE_LABELS = {
reachability: "Erreichbarkeit",
@@ -37,12 +40,14 @@ document.addEventListener("DOMContentLoaded", setupHealthTab);
// --- Health-Daten laden ---
async function loadHealthData() {
try {
const [health, suggestions] = await Promise.all([
const [health, suggestions, history] = await Promise.all([
API.get("/api/sources/health"),
API.get("/api/sources/suggestions"),
API.get("/api/sources/health/history?limit=10").catch(() => []),
]);
healthData = health;
suggestionsCache = suggestions;
healthHistoryCache = history || [];
renderHealthDashboard();
} catch (err) {
console.error("Health-Daten laden fehlgeschlagen:", err);
@@ -51,6 +56,22 @@ async function loadHealthData() {
}
}
function applyHealthFilter(checks) {
return checks.filter(c => {
if (healthFilters.status && 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"
&& healthFilters.org && String(c.tenant_id) !== healthFilters.org) return false;
return true;
});
}
function setHealthFilter(field, value) {
healthFilters[field] = value;
renderHealthDashboard();
}
function renderHealthDashboard() {
const container = document.getElementById("healthContent");
if (!container) return;
@@ -141,9 +162,20 @@ function renderHealthDashboard() {
// Health-Check Ergebnisse
let healthHtml = "";
if (healthData && healthData.checks && healthData.checks.length > 0) {
const issues = healthData.checks.filter((c) => c.status !== "ok");
// Filter anwenden
const allChecks = healthData.checks;
const filtered = applyHealthFilter(allChecks);
const okCount = healthData.checks.filter((c) => c.status === "ok").length;
// 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}` };
});
const checkTypes = Array.from(new Set(allChecks.map(c => c.check_type)));
healthHtml = `
<div class="card">
<div class="card-header">
@@ -155,25 +187,47 @@ function renderHealthDashboard() {
<span class="text-warning">${healthData.warnings} Warnungen</span> &nbsp;
<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="" ${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>
<option value="ok" ${healthFilters.status === "ok" ? "selected" : ""}>Nur OK</option>
</select>
<select class="filter-select" onchange="setHealthFilter('check_type', this.value)">
<option value="" ${healthFilters.check_type === "" ? "selected" : ""}>Alle Typen</option>
${checkTypes.map(ct => `<option value="${esc(ct)}" ${healthFilters.check_type === ct ? "selected" : ""}>${esc(CHECK_TYPE_LABELS[ct] || ct)}</option>`).join("")}
</select>
<select class="filter-select" onchange="setHealthFilter('org', this.value)">
<option value="all" ${healthFilters.org === "all" ? "selected" : ""}>Alle Quellen</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("")}
</select>
<span class="text-secondary" style="font-size:13px;">${filtered.length} / ${allChecks.length} Ergebnisse</span>
</div>
</div>`;
if (issues.length > 0) {
if (filtered.length > 0) {
healthHtml += `
<div class="table-wrap">
<table>
<thead>
<tr><th>Quelle</th><th>Domain</th><th>Typ</th><th>Status</th><th>Details</th><th>Aktionen</th></tr>
<tr><th>Quelle</th><th>Domain</th><th>Typ</th><th>Org</th><th>Sprache</th><th>Status</th><th>Details</th><th>Aktionen</th></tr>
</thead>
<tbody>
${issues
${filtered
.map(
(c) => `
<tr>
<td>${esc(c.name)}</td>
<td class="text-secondary">${esc(c.domain || "")}</td>
<td class="text-secondary">${esc(c.domain || "-")}</td>
<td>${CHECK_TYPE_LABELS[c.check_type] || c.check_type}</td>
<td><span class="badge badge-health-${c.status}">${c.status === "error" ? "Fehler" : "Warnung"}</span></td>
<td class="text-secondary" style="max-width:250px;">${esc(c.message)}</td>
<td class="text-secondary">${c.tenant_id == null ? '<span style="color:#94a3b8;">global</span>' : esc(c.org_name || ("Org " + c.tenant_id))}</td>
<td class="text-secondary">${esc(c.language || "-")}</td>
<td><span class="badge badge-health-${c.status}">${c.status === "error" ? "Fehler" : (c.status === "warning" ? "Warnung" : "OK")}</span></td>
<td class="text-secondary" style="max-width:250px;" title="${esc(c.message || "")}">${esc(c.message || "")}</td>
<td>${c.status === "error" && c.check_type === "reachability" ? `<button class="btn btn-secondary btn-small" data-source-id="${c.source_id}" data-source-name="${esc(c.name)}" onclick="searchFix(this)">Lösung suchen</button>` : ""}</td>
</tr>`,
)
@@ -182,7 +236,7 @@ function renderHealthDashboard() {
</table>
</div>`;
} else {
healthHtml += '<div class="card-body text-success">Alle Quellen sind gesund.</div>';
healthHtml += '<div class="card-body text-muted">Keine Ergebnisse mit diesen Filtern.</div>';
}
healthHtml += "</div>";
} else {
@@ -193,7 +247,32 @@ function renderHealthDashboard() {
</div>`;
}
container.innerHTML = suggestionsHtml + historyHtml + healthHtml;
// History-View: letzte Runs
let runsHtml = "";
if (healthHistoryCache.length > 0) {
runsHtml = `
<div class="card" style="margin-bottom:16px;">
<div class="card-header"><h2>Verlauf der Health-Check-Runs</h2></div>
<div class="table-wrap">
<table>
<thead><tr><th>Zeitpunkt (Run-Ende)</th><th>Run-ID</th><th>Total</th><th>Fehler</th><th>Warnungen</th><th>OK</th></tr></thead>
<tbody>
${healthHistoryCache.map(r => `
<tr>
<td>${formatDateTime(r.archived_at)}</td>
<td class="text-secondary"><code>${esc(r.run_id)}</code></td>
<td class="text-right">${r.total}</td>
<td class="text-right text-danger">${r.errors || 0}</td>
<td class="text-right text-warning">${r.warnings || 0}</td>
<td class="text-right text-success">${r.ok || 0}</td>
</tr>`).join("")}
</tbody>
</table>
</div>
</div>`;
}
container.innerHTML = suggestionsHtml + historyHtml + healthHtml + runsHtml;
}
// --- Vorschlag annehmen/ablehnen ---