Dateien
AegisSight-Monitor-Verwaltung/src/static/js/sources.js
claude-dev 14e766173c feat(sources): Knopf Jetzt pruefen startet den Health-Check aus dem Portal
- POST /api/sources/health/run (202, bei laufendem Run 409) stoesst den
  Lauf als BackgroundTask an: eigene DB-Connection, run_health_checks +
  generate_suggestions aus den geteilten Diensten, exakt nach dem Vorbild
  des Monitor-Nachtjobs daily_source_health_check. Kein Code-Duplikat.
- GET /api/sources/health/run-status liefert den In-Memory-Status
  (health_run.py, pure Funktionen mit Doppelstart-Sperre, unit-getestet).
  Nach einem Neustart ist der Status idle, ein partieller Stand wird vom
  naechsten Lauf archiviert und ueberschrieben (wie beim Nachtjob).
- Frontend: Knopf Jetzt pruefen + Zuletzt geprueft in der Stats-Leiste
  der Quellenliste, Polling alle 4 s, Abschluss-Toast mit geprueft/
  Probleme/neue Vorschlaege, danach Liste + Aufgaben-Badge neu laden.
  Beim Oeffnen des Reiters wird ein laufender Run aus anderer Session
  erkannt und weiterverfolgt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 18:14:14 +00:00

846 Zeilen
35 KiB
JavaScript

/* Vereinte Quellenliste (Grund- + Kundenquellen) */
"use strict";
// Hält Grund- UND Kundenquellen (GET /api/sources?scope=all)
let globalSourcesCache = [];
// Auswahl für Bulk-Promote (nur bei Herkunfts-Filter Kundenquellen sichtbar)
let tenantSelected = new Set();
// Aktiver Herkunfts-Filter ("" = alle, "global", "tenant")
let originFilter = "";
// Lazy-Cache für die Health-Ausklapp-Details je Quelle
const sourceHealthDetailCache = new Map();
let editingSourceId = null;
let globalSortField = "category";
let globalSortAsc = true;
const CHECK_TYPE_LABELS = {
reachability: "Erreichbarkeit",
feed_validity: "Feed-Validität",
stale: "Aktualität",
duplicate: "Duplikat",
};
// CATEGORY_LABELS jetzt global (aus app.js loadMeta)
// TYPE_LABELS jetzt global (aus app.js loadMeta)
// META muss geladen sein, bevor Dropdowns befüllt werden (loadMeta läuft async beim Start)
async function ensureMeta() {
if (!window.META || !window.META.categories || !window.META.categories.length) {
if (typeof loadMeta === "function") await loadMeta();
}
}
// Modal-Selects ohne Leer-Option aus META befüllen
function fillSelect(el, items, opts = {}) {
if (!el) return;
el.innerHTML = "";
(items || []).forEach((it) => {
const o = document.createElement("option");
o.value = it.key;
o.textContent = it.label + (opts.disabledKeys && opts.disabledKeys.includes(it.key) && opts.disabledHint ? " " + opts.disabledHint : "");
if (opts.disabledKeys && opts.disabledKeys.includes(it.key)) o.disabled = true;
el.appendChild(o);
});
if (opts.value !== undefined) el.value = opts.value;
}
function fillSourceModalSelects() {
fillSelect(document.getElementById("sourceType"), (window.META && window.META.types) || [],
{ disabledKeys: ["pdf_document"], disabledHint: "(nur Upload)" });
fillSelect(document.getElementById("sourceCategory"), (window.META && window.META.categories) || []);
}
// --- Init ---
document.addEventListener("DOMContentLoaded", () => {
setupSourceSubTabs();
setupSourceForms();
// Beim Tab-Wechsel auf "Quellen" laden (+ Aufgaben-Badge aktualisieren)
document.querySelectorAll('.nav-tab[data-section="sources"]').forEach((tab) => {
tab.addEventListener("click", () => {
loadUnifiedSources();
if (typeof refreshTasksBadge === "function") refreshTasksBadge();
});
});
});
function setupSourceSubTabs() {
document.querySelectorAll("#sourceSubTabs .nav-tab").forEach((tab) => {
tab.addEventListener("click", () => {
const subtab = tab.dataset.subtab;
document.querySelectorAll("#sourceSubTabs .nav-tab").forEach((t) => t.classList.remove("active"));
tab.classList.add("active");
document.querySelectorAll("#sec-sources > .section").forEach((s) => s.classList.remove("active"));
document.getElementById("sub-" + subtab).classList.add("active");
if (subtab === "global-sources") loadUnifiedSources();
else if (subtab === "aufgaben") loadAufgaben();
else if (subtab === "x-scraper") loadXScraperAccounts();
});
});
}
// --- Vereinte Quellenliste ---
async function loadUnifiedSources() {
try {
await ensureMeta();
// Kategorien/Typen-Dropdowns aus META befüllen (idempotent)
if (window.META && window.META.categories && window.META.categories.length) {
populateSelect(document.getElementById("globalFilterCategory"), window.META.categories, "Alle Kategorien");
populateSelect(document.getElementById("globalFilterType"), window.META.types || [], "Alle Typen");
}
const [list, stats, languages] = await Promise.all([
API.get("/api/sources?scope=all"),
API.get("/api/sources/stats"),
API.get("/api/sources/languages").catch(() => []),
]);
globalSourcesCache = list;
sourceHealthDetailCache.clear();
populateSelect(
document.getElementById("globalFilterLanguage"),
(languages || []).map(l => ({ key: l, label: l })),
"Alle Sprachen",
);
// datalist fuer Edit-Modal
const dl = document.getElementById("languageSuggestions");
if (dl) {
dl.innerHTML = "";
(languages || []).forEach(l => {
const o = document.createElement("option");
o.value = l;
dl.appendChild(o);
});
}
renderUnifiedStats(stats);
filterUnifiedSources();
checkHealthRunOnLoad();
} catch (err) {
console.error("Quellenliste laden fehlgeschlagen:", err);
}
}
async function showSourceAudit(sourceId, sourceName) {
document.getElementById("auditTitle").textContent = `Audit-Spur: ${sourceName}`;
document.getElementById("auditContent").innerHTML = '<div class="text-muted">Lade...</div>';
openModal("modalAudit");
try {
const res = await API.get(`/api/audit-log?resource_type=source&resource_id=${sourceId}&limit=50`);
renderAuditEntries(res.items || []);
} catch (err) {
document.getElementById("auditContent").innerHTML =
`<div class="text-danger">Audit konnte nicht geladen werden: ${esc(err.message || String(err))}</div>`;
}
}
function renderAuditEntries(items) {
const c = document.getElementById("auditContent");
if (!items.length) {
c.innerHTML = '<div class="text-muted">Keine Audit-Eintr&auml;ge f&uuml;r diese Quelle.</div>';
return;
}
c.innerHTML = items.map(e => {
const meta = `${formatDateTime(e.ts)} &middot; ${esc(e.admin_username || "-")} &middot; ${esc(e.ip || "-")}`;
const hasDiff = (e.before && Object.keys(e.before).length) || (e.after && Object.keys(e.after).length);
const diffPayload = JSON.stringify({ before: e.before, after: e.after }, null, 2);
return `
<div class="audit-entry">
<div class="audit-entry-head">
<span class="audit-entry-action audit-action-${esc(e.action)}">${esc(e.action)}</span>
<span class="audit-entry-meta">${meta}</span>
</div>
${hasDiff ? `<details class="audit-entry-detail">
<summary>Diff anzeigen</summary>
<pre>${esc(diffPayload)}</pre>
</details>` : ""}
</div>
`;
}).join("");
}
function formatDateTime(iso) {
if (!iso) return "-";
try {
const d = new Date(iso);
return d.toLocaleString("de-DE", {
day: "2-digit", month: "2-digit", year: "numeric",
hour: "2-digit", minute: "2-digit",
});
} catch { return iso; }
}
function renderUnifiedStats(stats) {
const bar = document.getElementById("globalStatsBar");
if (!bar) return;
if (!stats || !stats.by_type) { bar.innerHTML = ""; return; }
const types = window.META && window.META.types ? window.META.types : [];
const parts = [];
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${stats.total || 0}</span> Quellen gesamt</span>`);
const bo = stats.by_origin || {};
if (bo.tenant) {
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${bo.global || 0}</span> Grundquellen</span>`);
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${bo.tenant}</span> Kundenquellen</span>`);
}
for (const t of types) {
const v = stats.by_type[t.key] || { count: 0, articles: 0 };
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${v.count}</span> ${esc(t.label)}</span>`);
}
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${stats.total_articles || 0}</span> Artikel</span>`);
const h = stats.health || { errors: 0, warnings: 0, ok: 0 };
if (h.errors) parts.push(`<span class="sources-stat-item health-error"><span class="sources-stat-value">${h.errors}</span> Fehler</span>`);
if (h.warnings) parts.push(`<span class="sources-stat-item health-warning"><span class="sources-stat-value">${h.warnings}</span> Warnungen</span>`);
if (h.ok) parts.push(`<span class="sources-stat-item health-ok"><span class="sources-stat-value">${h.ok}</span> OK</span>`);
const lastCheck = stats.last_check ? formatDateTime(stats.last_check) : "noch nie";
parts.push(`<span class="sources-stat-item health-run-item"><button class="btn btn-secondary btn-small" id="runHealthCheckBtn" onclick="startHealthRun()">Jetzt prüfen</button><span class="text-secondary" id="healthRunInfo">Zuletzt geprüft ${lastCheck}</span></span>`);
bar.innerHTML = parts.join("");
if (healthRunPollTimer) setHealthRunButton(true);
}
// --- Manueller Health-Run (Knopf in der Stats-Leiste) ---
let healthRunPollTimer = null;
async function startHealthRun() {
try {
await API.post("/api/sources/health/run", {});
setHealthRunButton(true);
beginHealthRunPolling();
showToast("Prüfung gestartet. Das kann einige Minuten dauern.", "info");
} catch (err) {
if (String(err.message || "").includes("bereits")) {
showToast("Es läuft bereits eine Prüfung.", "warning");
setHealthRunButton(true);
beginHealthRunPolling();
} else {
showToast("Start fehlgeschlagen. " + err.message, "error");
}
}
}
function beginHealthRunPolling() {
if (healthRunPollTimer) return;
healthRunPollTimer = setInterval(pollHealthRunStatus, 4000);
}
async function pollHealthRunStatus() {
try {
const st = await API.get("/api/sources/health/run-status");
if (st.running) { setHealthRunButton(true); return; }
// Lauf beendet
if (healthRunPollTimer) { clearInterval(healthRunPollTimer); healthRunPollTimer = null; }
setHealthRunButton(false);
if (st.result) {
const r = st.result;
showToast(`Health-Check abgeschlossen. ${r.checked} geprüft, ${r.issues} Probleme, ${r.suggestions} neue Vorschläge.`, "success");
} else if (st.error) {
showToast("Health-Check fehlgeschlagen. " + st.error, "error");
} else {
showToast("Prüfung beendet, Ergebnis unbekannt (Server wurde neu gestartet).", "info");
}
loadUnifiedSources();
if (typeof refreshTasksBadge === "function") refreshTasksBadge();
} catch (_) { /* nächster Tick versucht es erneut */ }
}
function setHealthRunButton(running) {
const btn = document.getElementById("runHealthCheckBtn");
if (!btn) return;
btn.disabled = running;
btn.textContent = running ? "Prüfung läuft…" : "Jetzt prüfen";
}
// Beim Öffnen des Quellen-Reiters einmal nachsehen, ob (z.B. aus einer anderen
// Session) gerade ein Lauf aktiv ist, und dann das Polling aufnehmen.
async function checkHealthRunOnLoad() {
try {
const st = await API.get("/api/sources/health/run-status");
if (st.running) {
setHealthRunButton(true);
beginHealthRunPolling();
}
} catch (_) { /* unkritisch */ }
}
function renderUnifiedSources(sources) {
const tbody = document.getElementById("globalSourceTable");
const cols = 14;
if (sources.length === 0) {
tbody.innerHTML = `<tr><td colspan="${cols}" class="text-muted">Keine Quellen</td></tr>`;
updateBulkButton();
return;
}
// Nach Kategorie gruppieren (Reihenfolge beibehalten)
const grouped = {};
const order = [];
sources.forEach((s) => {
const cat = s.category || "sonstige";
if (!grouped[cat]) { grouped[cat] = []; order.push(cat); }
grouped[cat].push(s);
});
let html = "";
order.forEach((cat) => {
const label = CATEGORY_LABELS[cat] || cat;
const count = grouped[cat].length;
html += `<tr class="cat-header-row"><td colspan="${cols}"><span class="cat-header-label">${esc(label)}</span><span class="cat-header-count">${count}</span></td></tr>`;
grouped[cat].forEach((s) => {
const isTenant = s.tenant_id != null;
const hasNotes = s.notes && s.notes.trim();
const infoBtn = hasNotes
? `<span class="src-info-toggle" onclick="toggleSourceInfo(${s.id})" title="Info einblenden">&#9432;</span>`
: '';
// EINE gemeinsame Detailzeile je Quelle: Notizen + Health-Details.
const hasDetail = hasNotes || s.health_status;
const detailRow = hasDetail
? `<tr class="src-notes-row" id="detail-${s.id}" style="display:none;"><td colspan="${cols}" class="src-notes-cell">${hasNotes ? `<div class="src-notes-text">${esc(s.notes)}</div>` : ""}${s.health_status ? `<div class="health-detail" id="health-detail-${s.id}"></div>` : ""}</td></tr>`
: '';
const lastSeen = s.last_seen_at ? formatDate(s.last_seen_at) : "-";
const hs = s.health_status || "unknown";
const hsLabel = { error: "Fehler", warning: "Warnung", ok: "OK", unknown: "—" }[hs];
const hsClass = "health-badge-" + (hs === "unknown" ? "unknown" : hs);
const healthCell = s.health_status
? `<span class="health-badge ${hsClass} health-badge-btn" onclick="toggleHealthDetail(${s.id})" title="Health-Details anzeigen" role="button" tabindex="0">${hsLabel}</span>`
: `<span class="health-badge ${hsClass}">${hsLabel}</span>`;
const selectCell = isTenant
? `<td class="select-col"><input type="checkbox" class="tenant-select" data-id="${s.id}" ${tenantSelected.has(s.id) ? "checked" : ""} onchange="toggleTenantSelect(${s.id}, this.checked)"></td>`
: `<td class="select-col"></td>`;
// Kundenquellen sind bewusst nicht editierbar: Workflow ist erst
// "Übernehmen" (Promote zur Grundquelle), dann als Grundquelle bearbeiten.
const actions = isTenant
? `<button class="btn btn-primary btn-small" onclick="promoteSource(${s.id}, '${esc(s.name)}')">Übernehmen</button>
<button class="btn btn-secondary btn-small" onclick="showSourceAudit(${s.id}, '${esc(s.name)}')">Audit</button>`
: `<button class="btn btn-secondary btn-small" onclick="editGlobalSource(${s.id})">Bearbeiten</button>
<button class="btn btn-secondary btn-small" onclick="showSourceAudit(${s.id}, '${esc(s.name)}')">Audit</button>
<button class="btn btn-danger btn-small" onclick="confirmDeleteGlobalSource(${s.id}, '${esc(s.name)}')">Löschen</button>`;
html += `<tr>
${selectCell}
<td>${infoBtn} ${esc(s.name)}</td>
<td class="text-secondary" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.url || '')}">${esc(s.url || "-")}</td>
<td>${esc(s.domain || "-")}</td>
<td>${typeLabel(s.source_type)}</td>
<td class="text-right">${s.article_count || 0}</td>
<td class="${(s.articles_30d || 0) === 0 ? "activity-cell activity-zero" : "activity-cell"}" title="7 Tage / 30 Tage"><strong>${s.articles_7d || 0}</strong> / ${s.articles_30d || 0}</td>
<td class="text-secondary">${esc(s.language || "-")}</td>
<td class="text-secondary" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.bias || "")}">${esc(s.bias || "-")}</td>
<td class="text-secondary">${lastSeen}</td>
<td>${healthCell}</td>
<td><span class="badge badge-${s.status === "active" ? "active" : "inactive"}">${s.status === "active" ? "Aktiv" : "Inaktiv"}</span></td>
<td class="text-secondary">${isTenant ? esc(s.org_name || ("Org " + s.tenant_id)) : '<span class="text-muted">—</span>'}</td>
<td>${actions}</td>
</tr>${detailRow}`;
});
});
tbody.innerHTML = html;
const originLabel = originFilter === "global" ? "Grundquellen" : originFilter === "tenant" ? "Kundenquellen" : "Quellen";
document.getElementById("globalSourceCount").textContent = `${sources.length} ${originLabel}`;
updateBulkButton();
// Sort-Icons aktualisieren
document.querySelectorAll("#unifiedTable th.sortable .sort-icon").forEach(el => el.textContent = "");
const activeHeader = document.querySelector(`#unifiedTable th.sortable[data-sort="${globalSortField}"] .sort-icon`);
if (activeHeader) activeHeader.textContent = globalSortAsc ? " ▲" : " ▼";
}
// Filter + Sortierung
document.addEventListener("DOMContentLoaded", () => {
const el = document.getElementById("globalSourceSearch");
if (el) {
el.addEventListener("input", () => filterUnifiedSources());
}
});
function filterUnifiedSources() {
const q = (document.getElementById("globalSourceSearch")?.value || "").toLowerCase();
const typeFilter = document.getElementById("globalFilterType")?.value || "";
const catFilter = document.getElementById("globalFilterCategory")?.value || "";
const statusFilter = document.getElementById("globalFilterStatus")?.value || "";
const langFilter = document.getElementById("globalFilterLanguage")?.value || "";
const newOrigin = document.getElementById("filterOrigin")?.value || "";
if (newOrigin !== originFilter) {
originFilter = newOrigin;
tenantSelected.clear();
}
updateOriginControls();
let filtered = globalSourcesCache.filter((s) => {
if (originFilter === "global" && s.tenant_id != null) return false;
if (originFilter === "tenant" && s.tenant_id == null) return false;
if (q && !(s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.url || "").toLowerCase().includes(q) || (s.bias || "").toLowerCase().includes(q) || (s.org_name || "").toLowerCase().includes(q))) return false;
if (typeFilter && s.source_type !== typeFilter) return false;
if (catFilter && s.category !== catFilter) return false;
if (statusFilter && s.status !== statusFilter) return false;
if (langFilter && s.language !== langFilter) return false;
return true;
});
// Sortierung anwenden
filtered.sort((a, b) => {
let va = a[globalSortField] ?? "";
let vb = b[globalSortField] ?? "";
const NUMERIC_FIELDS = ["article_count", "articles_7d", "articles_30d"];
if (NUMERIC_FIELDS.includes(globalSortField)) {
va = parseInt(va) || 0;
vb = parseInt(vb) || 0;
return globalSortAsc ? va - vb : vb - va;
}
va = String(va).toLowerCase();
vb = String(vb).toLowerCase();
const cmp = va.localeCompare(vb, "de");
return globalSortAsc ? cmp : -cmp;
});
renderUnifiedSources(filtered);
}
function sortUnifiedSources(field) {
if (globalSortField === field) {
globalSortAsc = !globalSortAsc;
} else {
globalSortField = field;
globalSortAsc = true;
}
filterUnifiedSources();
}
// Checkbox-Spalte + Bulk-Knopf nur bei Herkunfts-Filter "Kundenquellen"
function updateOriginControls() {
const showSelect = originFilter === "tenant";
const table = document.getElementById("unifiedTable");
if (table) table.classList.toggle("show-select", showSelect);
const btn = document.getElementById("tenantBulkPromoteBtn");
if (btn) btn.style.display = showSelect ? "" : "none";
}
// --- Grundquelle erstellen/bearbeiten ---
async function openNewGlobalSource() {
editingSourceId = null;
document.getElementById("sourceModalTitle").textContent = "Neue Grundquelle";
document.getElementById("sourceForm").reset();
await ensureMeta();
fillSourceModalSelects();
document.getElementById("sourceType").value = "rss_feed";
document.getElementById("sourceCategory").value = "sonstige";
openModal("modalSource");
}
async function editGlobalSource(id) {
const s = globalSourcesCache.find((x) => x.id === id);
if (!s) return;
editingSourceId = id;
await ensureMeta();
fillSourceModalSelects();
document.getElementById("sourceModalTitle").textContent = "Grundquelle bearbeiten";
document.getElementById("sourceName").value = s.name;
document.getElementById("sourceUrl").value = s.url || "";
document.getElementById("sourceDomain").value = s.domain || "";
document.getElementById("sourceType").value = s.source_type;
document.getElementById("sourceCategory").value = s.category;
document.getElementById("sourceStatus").value = s.status;
document.getElementById("sourceNotes").value = s.notes || "";
document.getElementById("sourceLanguage").value = s.language || "";
document.getElementById("sourceBias").value = s.bias || "";
document.getElementById("sourceFetchStrategy").value = s.fetch_strategy || "default";
document.getElementById("sourcePolitical").value = s.political_orientation || "";
document.getElementById("sourceMediaType").value = s.media_type || "";
document.getElementById("sourceReliability").value = s.reliability || "";
document.getElementById("sourceCountryCode").value = s.country_code || "";
document.getElementById("sourceStateAffiliated").checked = !!s.state_affiliated;
openModal("modalSource");
}
function setupSourceForms() {
document.getElementById("newGlobalSourceBtn").addEventListener("click", openNewGlobalSource);
document.getElementById("newPdfSourceBtn")?.addEventListener("click", openPdfUploadModal);
setupPdfUploadForm();
document.getElementById("discoverSourceBtn").addEventListener("click", () => {
document.getElementById("discoverUrl").value = "";
document.getElementById("discoverStatus").style.display = "none";
document.getElementById("discoverResults").style.display = "none";
openModal("modalDiscover");
});
document.getElementById("sourceForm").addEventListener("submit", async (e) => {
e.preventDefault();
const errEl = document.getElementById("sourceError");
errEl.style.display = "none";
const body = {
name: document.getElementById("sourceName").value,
url: document.getElementById("sourceUrl").value || null,
domain: document.getElementById("sourceDomain").value || null,
status: document.getElementById("sourceStatus").value,
notes: document.getElementById("sourceNotes").value || null,
language: document.getElementById("sourceLanguage").value || null,
bias: document.getElementById("sourceBias").value || null,
fetch_strategy: document.getElementById("sourceFetchStrategy").value || "default",
};
// Leere Werte NIE mitsenden. Ein leerer Select-Wert (z.B. META noch nicht
// geladen) würde sonst die Kategorie serverseitig still überschreiben.
const st = document.getElementById("sourceType").value;
if (st) body.source_type = st;
const cat = document.getElementById("sourceCategory").value;
if (cat) body.category = cat;
const pol = document.getElementById("sourcePolitical")?.value;
if (pol) body.political_orientation = pol;
const mt = document.getElementById("sourceMediaType")?.value;
if (mt) body.media_type = mt;
const rel = document.getElementById("sourceReliability")?.value;
if (rel) body.reliability = rel;
const cc = (document.getElementById("sourceCountryCode")?.value || "").trim().toUpperCase();
if (cc) body.country_code = cc;
if (editingSourceId) {
body.state_affiliated = !!document.getElementById("sourceStateAffiliated")?.checked;
}
try {
if (editingSourceId) {
await API.put("/api/sources/global/" + editingSourceId, body);
} else {
await API.post("/api/sources/global", body);
}
closeModal("modalSource");
loadUnifiedSources();
} catch (err) {
errEl.textContent = err.message;
errEl.style.display = "block";
}
});
// Domain aus URL ableiten
document.getElementById("sourceUrl").addEventListener("blur", (e) => {
const domainField = document.getElementById("sourceDomain");
if (domainField.value) return;
try {
const url = new URL(e.target.value);
domainField.value = url.hostname.replace(/^www\./, "");
} catch (_) {}
});
}
function confirmDeleteGlobalSource(id, name) {
showConfirm(
"Grundquelle löschen",
`Soll die Grundquelle "${name}" endgültig gelöscht werden? Sie wird für alle Monitore entfernt.`,
async () => {
try {
await API.del("/api/sources/global/" + id);
loadUnifiedSources();
} catch (err) {
showToast(err.message, "error");
}
}
);
}
// --- Bulk-Promote (Kundenquellen in der vereinten Liste) ---
function toggleTenantSelectAll(checked) {
document.querySelectorAll("#globalSourceTable input.tenant-select").forEach(cb => {
cb.checked = checked;
const id = parseInt(cb.dataset.id);
if (checked) tenantSelected.add(id); else tenantSelected.delete(id);
});
updateBulkButton();
}
function toggleTenantSelect(id, checked) {
id = parseInt(id);
if (checked) tenantSelected.add(id); else tenantSelected.delete(id);
updateBulkButton();
// Header-Checkbox anpassen
const visible = document.querySelectorAll("#globalSourceTable input.tenant-select").length;
const checkedVisible = document.querySelectorAll("#globalSourceTable input.tenant-select:checked").length;
const all = document.getElementById("unifiedSelectAll");
if (all) all.checked = visible > 0 && visible === checkedVisible;
}
function updateBulkButton() {
const btn = document.getElementById("tenantBulkPromoteBtn");
if (!btn) return;
const n = tenantSelected.size;
btn.disabled = n === 0;
btn.textContent = `Ausgewählte übernehmen (${n})`;
}
async function bulkPromoteSelected() {
if (tenantSelected.size === 0) return;
const ids = Array.from(tenantSelected);
const ok = await showConfirm(
"Ausgewählte als Grundquelle übernehmen",
`Sollen ${ids.length} Kundenquelle(n) als Grundquelle übernommen werden? Sie werden dann für alle Monitore verfügbar.`,
);
if (!ok) return;
try {
const result = await API.post("/api/sources/tenant/bulk-promote", { source_ids: ids });
let msg = `${result.promoted} übernommen`;
if (result.skipped && result.skipped.length) msg += `, ${result.skipped.length} übersprungen`;
if (result.failed && result.failed.length) msg += `, ${result.failed.length} Fehler`;
showToast(msg, result.failed && result.failed.length ? "warning" : "success");
tenantSelected.clear();
await loadUnifiedSources();
} catch (err) {
showToast("Bulk-Promote fehlgeschlagen: " + err.message, "error");
}
}
function promoteSource(id, name) {
showConfirm(
"Zur Grundquelle machen",
`Soll "${name}" als Grundquelle übernommen werden? Sie wird dann für alle Monitore verfügbar.`,
async () => {
try {
await API.post("/api/sources/tenant/" + id + "/promote");
tenantSelected.delete(id);
loadUnifiedSources();
} catch (err) {
showToast(err.message, "error");
}
}
);
}
// --- Health-Details in der Ausklapp-Zeile ---
async function toggleHealthDetail(id) {
const row = document.getElementById("detail-" + id);
if (!row) return;
const isVisible = row.style.display !== "none";
toggleSourceInfo(id);
if (isVisible) return; // wurde gerade zugeklappt
const box = document.getElementById("health-detail-" + id);
if (!box) return;
if (sourceHealthDetailCache.has(id)) {
renderHealthDetail(id, sourceHealthDetailCache.get(id));
return;
}
box.innerHTML = '<div class="text-muted">Lade Health-Details…</div>';
try {
const res = await API.get("/api/sources/" + id + "/health");
sourceHealthDetailCache.set(id, res.checks || []);
renderHealthDetail(id, res.checks || []);
} catch (err) {
box.innerHTML = `<div class="text-danger">Health-Details konnten nicht geladen werden. ${esc(err.message || "")}</div>`;
}
}
function renderHealthDetail(id, checks) {
const box = document.getElementById("health-detail-" + id);
if (!box) return;
const s = globalSourcesCache.find((x) => x.id === id);
const deactivateBtn = s && s.status === "active"
? `<button class="btn btn-danger btn-small" onclick="deactivateSource(${id}, '${esc(s.name)}')">Quelle deaktivieren</button>`
: "";
if (!checks.length) {
box.innerHTML = `<div class="health-detail-head"><span class="text-muted">Keine Health-Checks für diese Quelle vorhanden.</span>${deactivateBtn}</div>`;
return;
}
const statusLabel = (st) => st === "error" ? "Fehler" : st === "warning" ? "Warnung" : "OK";
const rows = checks.map((c) => `
<div class="health-detail-row">
<span class="health-badge health-badge-${c.status === "error" ? "error" : c.status === "warning" ? "warning" : "ok"}">${statusLabel(c.status)}</span>
<span class="health-detail-type">${CHECK_TYPE_LABELS[c.check_type] || esc(c.check_type)}</span>
<span class="health-detail-msg">${esc(c.message || "")}</span>
<span class="text-secondary health-detail-ts">${formatDateTime(c.checked_at)}</span>
</div>`).join("");
box.innerHTML = `<div class="health-detail-head"><strong>Health-Checks</strong>${deactivateBtn}</div>${rows}`;
}
function deactivateSource(id, name) {
showConfirm(
"Quelle deaktivieren",
`Soll die Quelle "${name}" deaktiviert werden? Sie wird dann nicht mehr abgerufen.`,
async () => {
try {
await API.put("/api/sources/global/" + id, { status: "inactive" });
showToast("Quelle wurde deaktiviert.", "success");
sourceHealthDetailCache.delete(id);
loadUnifiedSources();
} catch (err) {
showToast(err.message, "error");
}
}
);
}
// --- Discovery ---
let discoveredFeeds = [];
async function runDiscover() {
const url = document.getElementById("discoverUrl").value.trim();
if (!url) return;
const btn = document.getElementById("discoverBtn");
const statusEl = document.getElementById("discoverStatus");
const resultsEl = document.getElementById("discoverResults");
btn.disabled = true;
btn.textContent = "Suche...";
statusEl.style.display = "block";
statusEl.textContent = "Analysiere Website und suche RSS-Feeds...";
resultsEl.style.display = "none";
try {
const data = await API.post("/api/sources/discover?url=" + encodeURIComponent(url));
discoveredFeeds = data.feeds || [];
if (discoveredFeeds.length === 0 && (!data.existing || data.existing.length === 0)) {
statusEl.textContent = data.message || "Keine RSS-Feeds gefunden für " + data.domain;
return;
}
statusEl.style.display = "none";
resultsEl.style.display = "block";
// Bereits vorhandene anzeigen
const existingEl = document.getElementById("discoverExisting");
if (data.existing && data.existing.length > 0) {
existingEl.style.display = "block";
existingEl.innerHTML = '<div class="text-secondary" style="font-size:12px;margin-bottom:8px;">Bereits als Grundquelle vorhanden:</div>' +
data.existing.map(f => '<div style="padding:4px 0;font-size:13px;color:var(--text-tertiary);">&#10003; ' + esc(f.name) + '</div>').join("");
} else {
existingEl.style.display = "none";
}
// Neue Feeds mit Checkboxen
const feedsEl = document.getElementById("discoverFeeds");
if (discoveredFeeds.length > 0) {
feedsEl.innerHTML = '<div class="text-secondary" style="font-size:12px;margin-bottom:8px;">Neue Feeds gefunden (' + data.domain + ', ' + (CATEGORY_LABELS[data.category] || data.category) + '):</div>' +
discoveredFeeds.map((f, i) => `
<label style="display:flex;align-items:center;gap:8px;padding:6px 0;font-size:13px;cursor:pointer;">
<input type="checkbox" checked data-idx="${i}">
<span>${esc(f.name)}</span>
<span class="text-secondary" style="font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:250px;" title="${esc(f.url)}">${esc(f.url)}</span>
</label>
`).join("");
document.getElementById("addDiscoveredBtn").style.display = "";
} else {
feedsEl.innerHTML = '<div class="text-muted" style="font-size:13px;">Alle Feeds dieser Domain sind bereits als Grundquellen vorhanden.</div>';
document.getElementById("addDiscoveredBtn").style.display = "none";
}
} catch (err) {
statusEl.textContent = "Fehler: " + err.message;
} finally {
btn.disabled = false;
btn.textContent = "Erkennen";
}
}
async function addDiscoveredFeeds() {
const checkboxes = document.querySelectorAll("#discoverFeeds input[type=checkbox]:checked");
const selected = [];
checkboxes.forEach(cb => {
const idx = parseInt(cb.dataset.idx);
if (discoveredFeeds[idx]) selected.push(discoveredFeeds[idx]);
});
if (selected.length === 0) {
showToast("Keine Feeds ausgewählt", "warning");
return;
}
const btn = document.getElementById("addDiscoveredBtn");
btn.disabled = true;
btn.textContent = "Wird hinzugefügt...";
try {
const result = await API.post("/api/sources/discover/add", selected);
closeModal("modalDiscover");
loadUnifiedSources();
showToast(result.added + " Grundquelle(n) hinzugefügt" + (result.skipped ? ", " + result.skipped + " übersprungen" : ""), "success");
} catch (err) {
showToast("Fehler: " + err.message, "error");
} finally {
btn.disabled = false;
btn.textContent = "Ausgewählte hinzufügen";
}
}
function toggleSourceInfo(id) {
const row = document.getElementById("detail-" + id);
if (!row) return;
const isVisible = row.style.display !== "none";
row.style.display = isVisible ? "none" : "table-row";
const mainRow = row.previousElementSibling;
if (mainRow) {
const btn = mainRow.querySelector(".src-info-toggle");
if (btn) btn.classList.toggle("active", !isVisible);
}
}
// --- PDF-Quellen-Upload ---
async function openPdfUploadModal() {
const form = document.getElementById("pdfUploadForm");
if (form) form.reset();
await ensureMeta();
fillSelect(document.getElementById("pdfCategory"), (window.META && window.META.categories) || [], { value: "sonstige" });
const err = document.getElementById("pdfUploadError");
if (err) { err.style.display = "none"; err.textContent = ""; }
const prog = document.getElementById("pdfUploadProgress");
if (prog) prog.style.display = "none";
openModal("modalPdfUpload");
}
function setupPdfUploadForm() {
const form = document.getElementById("pdfUploadForm");
if (!form || form.dataset.bound === "1") return;
form.dataset.bound = "1";
form.addEventListener("submit", async (e) => {
e.preventDefault();
const errEl = document.getElementById("pdfUploadError");
const progEl = document.getElementById("pdfUploadProgress");
const submitBtn = document.getElementById("pdfUploadSubmitBtn");
errEl.style.display = "none";
const fileInput = document.getElementById("pdfFile");
const f = fileInput?.files?.[0];
if (!f) {
errEl.textContent = "Bitte eine PDF-Datei auswaehlen.";
errEl.style.display = "block";
return;
}
if (f.size > 50 * 1024 * 1024) {
errEl.textContent = "Datei ueberschreitet 50 MB.";
errEl.style.display = "block";
return;
}
const fd = new FormData();
fd.append("file", f);
const nm = document.getElementById("pdfName").value.trim();
if (nm) fd.append("name", nm);
fd.append("category", document.getElementById("pdfCategory").value || "sonstige");
const lng = document.getElementById("pdfLanguage").value.trim();
if (lng) fd.append("language", lng);
const nt = document.getElementById("pdfNotes").value.trim();
if (nt) fd.append("notes", nt);
submitBtn.disabled = true;
progEl.style.display = "block";
try {
await API.upload("/api/sources/global/upload-pdf", fd);
closeModal("modalPdfUpload");
if (typeof showToast === "function") {
showToast("PDF hochgeladen -- Verarbeitung laeuft im Hintergrund", "success");
}
loadUnifiedSources();
} catch (err) {
errEl.textContent = err.message || "Upload fehlgeschlagen";
errEl.style.display = "block";
} finally {
submitBtn.disabled = false;
progEl.style.display = "none";
}
});
}