/* Quellen-Health & Vorschläge */ "use strict"; let healthData = null; let suggestionsCache = []; // 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", feed_validity: "Feed-Validität", stale: "Aktualität", duplicate: "Duplikat", }; const SUGGESTION_TYPE_LABELS = { add_source: "Neue Quelle", deactivate_source: "Deaktivieren", remove_source: "Entfernen", fix_url: "URL korrigieren", }; const PRIORITY_LABELS = { high: "Hoch", medium: "Mittel", low: "Niedrig", }; // Lucide-Icons als Inline-SVG-Konstanten (statt CDN-Abhängigkeit oder Emojis). // 14x14, currentColor erbt vom Button-Style. const LUCIDE_ICONS = { check: '', x: '', search:'', refresh:'', }; // --- Init --- function setupHealthTab() { const tab = document.querySelector('#sourceSubTabs .nav-tab[data-subtab="source-health"]'); if (tab) { tab.addEventListener("click", () => loadHealthData()); } } // Sub-Sub-Tabs innerhalb von Quellen-Health: Vorschläge / Health-Status / Verlauf. function setupHealthSubTabs() { document.querySelectorAll("#healthSubTabs .nav-tab").forEach((tab) => { tab.addEventListener("click", () => { const which = tab.dataset.healthtab; document.querySelectorAll("#healthSubTabs .nav-tab").forEach(t => t.classList.remove("active")); tab.classList.add("active"); ["suggestions", "checks", "verlauf"].forEach(name => { const pane = document.getElementById("ht-" + name); if (pane) pane.style.display = name === which ? "block" : "none"; }); }); }); } document.addEventListener("DOMContentLoaded", () => { setupHealthTab(); setupHealthSubTabs(); }); // --- Health-Daten laden --- 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?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); document.getElementById("healthContent").innerHTML = '
Fehler beim Laden der Health-Daten.
'; } } // Pagination-Steuerung: hochsetzen + neu laden function loadMoreHealth() { healthLoadLimit += 200; loadHealthData(true); } function loadAllHealth() { healthLoadLimit = 100000; loadHealthData(true); } function applyHealthFilter(checks) { return checks.filter(c => { // "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" && healthFilters.org && String(c.tenant_id) !== healthFilters.org) return false; return true; }); } function setHealthFilter(field, value) { healthFilters[field] = value; renderHealthDashboard(); } function renderHealthDashboard() { // Drei Sub-Panes (statt einer monolithischen Health-Section). const paneSuggestions = document.getElementById("ht-suggestions"); const paneChecks = document.getElementById("ht-checks"); const paneVerlauf = document.getElementById("ht-verlauf"); if (!paneSuggestions || !paneChecks || !paneVerlauf) return; // Vorschläge rendern const pendingSuggestions = suggestionsCache.filter((s) => s.status === "pending"); const recentSuggestions = suggestionsCache.filter((s) => s.status !== "pending"); let suggestionsHtml = ""; if (pendingSuggestions.length > 0) { suggestionsHtml = `

Vorschläge (${pendingSuggestions.length} offen)

${pendingSuggestions .map( (s) => ` `, ) .join("")}
Typ Titel Beschreibung Priorität Erstellt Aktionen
${SUGGESTION_TYPE_LABELS[s.suggestion_type] || s.suggestion_type} ${esc(s.title)} ${esc(s.description || "")} ${PRIORITY_LABELS[s.priority] || s.priority} ${formatDateTime(s.created_at)} ${s.suggestion_type === "deactivate_source" && s.source_id ? ` ` : ""}
`; } else { suggestionsHtml = `

Vorschläge

Keine offenen Vorschläge vorhanden.
`; } // Vergangene Vorschläge - eingeklappt by default, weil rein historisch. let historyHtml = ""; if (recentSuggestions.length > 0) { const shown = recentSuggestions.slice(0, 20); historyHtml = `
Verlauf (${recentSuggestions.length} erledigte Vorschläge - klick zum Aufklappen)
${shown .map( (s) => ` `, ) .join("")}
TypTitelStatusBearbeitet
${SUGGESTION_TYPE_LABELS[s.suggestion_type] || s.suggestion_type} ${esc(s.title)} ${s.status === "accepted" ? "Angenommen" : "Abgelehnt"} ${formatDateTime(s.reviewed_at)}
`; } // Health-Check Ergebnisse let healthHtml = ""; if (healthData && healthData.checks && healthData.checks.length > 0) { // Filter anwenden const allChecks = healthData.checks; const filtered = applyHealthFilter(allChecks); // 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 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 `${total} ${label} (${esc(detail)})`; } // Trend-Delta zum vorletzten Run (healthHistoryCache[1]). Index 0 ist // typischerweise der aktuelle Stand, Index 1 der davor archivierte Run. // Wenn weniger als 2 Runs in der History: kein Delta anzeigen. const prevRun = (healthHistoryCache && healthHistoryCache.length > 1) ? healthHistoryCache[1] : null; function deltaBadge(currentValue, prevValue, badIsUp) { if (prevValue == null) return ""; const d = currentValue - prevValue; if (d === 0) return ` (±0)`; const sign = d > 0 ? "+" : ""; // badIsUp=true: Anstieg = schlecht (rot), Abnahme = gut (grün). Umgekehrt für OK. const cls = (badIsUp ? (d > 0) : (d < 0)) ? "text-danger" : "text-success"; return ` (${sign}${d})`; } const dErr = prevRun ? deltaBadge(healthData.errors, prevRun.errors, true) : ""; const dWarn = prevRun ? deltaBadge(healthData.warnings, prevRun.warnings, true) : ""; const dOk = prevRun ? deltaBadge(okCount, prevRun.ok, false) : ""; healthHtml = `

Health-Check Ergebnisse

Letzter Check: ${healthData.last_check ? formatDateTime(healthData.last_check) : "Noch nie"}  |  ${breakdownLine("error", "text-danger") || `0 Fehler`}${dErr}   ${breakdownLine("warning", "text-warning") || `0 Warnungen`}${dWarn}   ${okCount} OK${dOk}
${filtered.length} / ${allChecks.length} angezeigt${totalAll > allChecks.length ? ` (von ${totalAll} insgesamt)` : ''}
`; if (filtered.length > 0) { healthHtml += `
${filtered .map( (c) => { // Domain + Sprache in Tooltip vom Quellnamen, statt eigene Spalten. const tipParts = []; if (c.domain) tipParts.push(c.domain); if (c.language) tipParts.push(c.language); const nameTip = tipParts.length ? ` title="${esc(tipParts.join(" · "))}"` : ""; return ` ${esc(c.name)} `; } ) .join("")}
QuelleTypOrgStatusDetailsAktion
${CHECK_TYPE_LABELS[c.check_type] || c.check_type} ${c.tenant_id == null ? 'global' : esc(c.org_name || ("Org " + c.tenant_id))} ${c.status === "error" ? "Fehler" : (c.status === "warning" ? "Warnung" : "OK")} ${esc(c.message || "")} ${( (c.status === "error" && c.check_type === "reachability") || (c.status === "warning" && c.check_type === "feed_validity") ) ? `` : ""}
`; } 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 += `
Keine Treffer in den geladenen ${allChecks.length} von ${totalAll} Items mit dem aktuellen Filter. Alle ${totalAll} Health-Checks laden und Filter erneut anwenden.
`; } else { healthHtml += '
Keine Ergebnisse mit diesen Filtern.
'; } // Footer mit Mehr-laden-Buttons, falls Backend has_more meldet if (hasMore) { const remaining = Math.max(0, totalAll - allChecks.length); healthHtml += `
${allChecks.length} von ${totalAll} geladen
`; } healthHtml += "
"; } else { healthHtml = `

Health-Check Ergebnisse

Noch kein Health-Check durchgeführt.
`; } // History-View: letzte Runs. Kompakt: Total raus (= errors+warnings+ok), // Spalten-Widths explizit, Zahlen zentriert, Run-ID gekürzt + leiser. let runsHtml = ""; if (healthHistoryCache.length > 0) { runsHtml = `

Verlauf der Health-Check-Runs

${healthHistoryCache.map(r => ` `).join("")}
Zeitpunkt Run-ID Fehler Warnungen OK
${formatDateTime(r.archived_at)} ${esc(String(r.run_id || "").slice(0, 12))} ${r.errors || 0} ${r.warnings || 0} ${r.ok || 0}
`; } // Statt einer monolithischen Render: drei Sub-Panes, einer pro Sub-Tab. paneSuggestions.innerHTML = suggestionsHtml; paneChecks.innerHTML = healthHtml; paneVerlauf.innerHTML = historyHtml + runsHtml; // Tab-Label "Vorschläge" mit Counter der offenen Vorschläge anreichern. const tabBtnSugg = document.querySelector('#healthSubTabs .nav-tab[data-healthtab="suggestions"]'); if (tabBtnSugg) { const open = pendingSuggestions.length; tabBtnSugg.textContent = open > 0 ? `Vorschläge (${open} offen)` : "Vorschläge"; } } // --- Vorschlag annehmen/ablehnen --- async function handleSuggestion(id, accept) { const action = accept ? "annehmen" : "ablehnen"; const suggestion = suggestionsCache.find((s) => s.id === id); if (!suggestion) return; const ok = await showConfirm("Vorschlag " + (action === "annehmen" ? "annehmen" : "ablehnen"), `Soll "${suggestion.title}" ${action}?`); if (!ok) return; try { const result = await API.put("/api/sources/suggestions/" + id, { accept }); if (result.action) { showToast("Ergebnis: " + result.action, "success"); } loadHealthData(true); // Grundquellen-Liste auch aktualisieren if (typeof loadGlobalSources === "function") loadGlobalSources(); } catch (err) { showToast("Fehler: " + err.message, "error"); } } // --- Health-Check manuell starten --- async function runHealthCheck() { const btn = document.getElementById("runHealthCheckBtn"); if (!btn) return; btn.disabled = true; // Fortschrittsanzeige erstellen let progressEl = document.getElementById("healthProgress"); if (!progressEl) { progressEl = document.createElement("div"); progressEl.id = "healthProgress"; progressEl.style.cssText = "display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:16px;font-size:13px;"; btn.parentElement.after(progressEl); } progressEl.style.display = "flex"; function updateProgress(data) { if (data.phase === "check") { const pct = data.total > 0 ? Math.round((data.checked / data.total) * 100) : 0; const statusIcon = data.status === "error" ? "\u2717" : data.status === "warning" ? "\u26A0" : "\u2713"; btn.textContent = data.checked + "/" + data.total; progressEl.innerHTML = '
' + '
' + '' + (data.current ? statusIcon + " " + esc(data.current) : "Starte...") + '' + '' + pct + '%' + '
' + '
' + '
' + '
' + '
'; } else if (data.phase === "suggestions") { progressEl.innerHTML = 'Generiere Vorschl\u00e4ge...'; } else if (data.phase === "done") { progressEl.innerHTML = '' + data.checked + ' gepr\u00fcft, ' + data.issues + ' Probleme, ' + data.suggestions + ' Vorschl\u00e4ge'; setTimeout(function() { progressEl.style.display = "none"; }, 5000); } } try { const headers = { "Content-Type": "application/json" }; if (API.token) headers["Authorization"] = "Bearer " + API.token; const response = await fetch("/api/sources/health/run-stream", { method: "POST", headers: headers, }); if (!response.ok) { throw new Error("HTTP " + response.status); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (line.startsWith("data: ")) { try { const data = JSON.parse(line.slice(6)); updateProgress(data); } catch (_) {} } } } loadHealthData(true); } catch (err) { progressEl.innerHTML = 'Fehler: ' + esc(err.message) + ''; } finally { btn.disabled = false; btn.textContent = "Jetzt pr\u00fcfen"; } } // --- Hilfsfunktionen --- function formatDateTime(dateStr) { if (!dateStr) return "-"; try { const d = new Date(dateStr); return d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", }); } catch (_) { return dateStr; } } // --- Sonnet-Recherche für kaputte Quelle --- async function searchFix(btn) { const sourceId = btn.dataset.sourceId; const sourceName = btn.dataset.sourceName; const ok = await showConfirm("Lösung suchen", `Sonnet mit WebSearch nach einer Lösung für "${sourceName}" suchen lassen? Das kann einige Minuten dauern.`); if (!ok) return; btn.disabled = true; btn.textContent = "Sucht..."; try { const result = await API.post("/api/sources/health/search-fix/" + sourceId); let msg = result.summary || "Keine Zusammenfassung"; if (result.solutions && result.solutions.length > 0) { msg += "\n\nGefundene Lösungen als Vorschläge gespeichert."; } if (result.cost_usd) { msg += `\n\nKosten: $${result.cost_usd.toFixed(2)}`; } showToast(msg, "info"); loadHealthData(true); } catch (err) { showToast("Fehler: " + err.message, "error"); } finally { btn.disabled = false; btn.textContent = "Lösung suchen"; } }