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:
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
<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 ---
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren