/* 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 = `
Typ
Titel
Beschreibung
Priorität
Erstellt
Aktionen
${pendingSuggestions
.map(
(s) => `
${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 ? `${LUCIDE_ICONS.search} ` : ""}
${LUCIDE_ICONS.check}
${LUCIDE_ICONS.x}
`,
)
.join("")}
`;
} else {
suggestionsHtml = `
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)
Typ Titel Status Bearbeitet
${shown
.map(
(s) => `
${SUGGESTION_TYPE_LABELS[s.suggestion_type] || s.suggestion_type}
${esc(s.title)}
${s.status === "accepted" ? "Angenommen" : "Abgelehnt"}
${formatDateTime(s.reviewed_at)}
`,
)
.join("")}
`;
}
// 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 = `
Nur Probleme (Default)
Alle Status
Nur Fehler
Nur Warnungen
Nur OK
Alle Typen
${checkTypes.map(ct => `${esc(CHECK_TYPE_LABELS[ct] || ct)} `).join("")}
Alle Quellen
Nur Grundquellen
${orgs.map(o => `Org: ${esc(o.name)} `).join("")}
${filtered.length} / ${allChecks.length} angezeigt${totalAll > allChecks.length ? ` (von ${totalAll} insgesamt)` : ''}
`;
if (filtered.length > 0) {
healthHtml += `
Quelle Typ Org Status Details Aktion
${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)}
${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")
) ? `${LUCIDE_ICONS.search} ` : ""}
`;
}
)
.join("")}
`;
} 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
+200 laden
Alle ${remaining} weiteren laden
`;
}
healthHtml += "
";
} else {
healthHtml = `
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 = `
Zeitpunkt
Run-ID
Fehler
Warnungen
OK
${healthHistoryCache.map(r => `
${formatDateTime(r.archived_at)}
${esc(String(r.run_id || "").slice(0, 12))}
${r.errors || 0}
${r.warnings || 0}
${r.ok || 0}
`).join("")}
`;
}
// 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";
}
}