feat(sources): Aufgaben-Posteingang ersetzt Quellen-Health und Klassifikation
- Neuer Unterreiter Aufgaben buendelt Health-Vorschlaege (Tabelle inkl. Loesung-suchen), Klassifikations-Review-Karten und den eingeklappten Verlauf (erledigte Vorschlaege + letzte Prueflaeufe). Die dritte Navigationsebene (healthSubTabs) entfaellt komplett. - Badge zeigt die Summe aus offenen Vorschlaegen und ausstehenden Klassifikationen, gespeist vom neuen billigen GET /api/sources/tasks/summary (gleiche Bedingung wie /classification/stats, laeuft nie auseinander). Badge laedt jetzt schon beim Klick auf den Quellen-Hauptreiter. - aufgaben.js NEU (uebernimmt Vorschlags-/Verlauf-Rendering aus source-health.js und den kompletten Klassifikations-Block aus sources.js), source-health.js GELOESCHT (60s-Cache, Checks-Tabelle mit eigener Pagination und Filtern entfaellt, die Details stecken jetzt als Ausklapp-Zeile in der Quellenliste). - Quellen hat damit 3 statt 5 Unterreiter: Quellenliste, Aufgaben, X-Recherche-Konten. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -48,7 +48,7 @@ src/:
|
|||||||
organizations.py: "CRUD Mandanten (organizations + Org-Settings + Token-Budget)"
|
organizations.py: "CRUD Mandanten (organizations + Org-Settings + Token-Budget)"
|
||||||
licenses.py: "CRUD Lizenzen (Org-Lizenzen, Ablauf, Nutzer-Limit, Module, Credits-Stellwerte inkl. Periode/Übertrag; Helper insert_license_row wird auch vom Org-Anlegen genutzt)"
|
licenses.py: "CRUD Lizenzen (Org-Lizenzen, Ablauf, Nutzer-Limit, Module, Credits-Stellwerte inkl. Periode/Übertrag; Helper insert_license_row wird auch vom Org-Anlegen genutzt)"
|
||||||
users.py: "CRUD User pro Org, Magic-Link-Einladung an info@aegis-sight.de"
|
users.py: "CRUD User pro Org, Magic-Link-Einladung an info@aegis-sight.de"
|
||||||
sources.py: "Grundquellen, Tenant-Quellen-Übersicht, Discovery, Health-Check, KI-Vorschläge"
|
sources.py: "Vereinte Quellenliste (GET /api/sources?scope=all|global|tenant inkl. org_name + health_status), Stats/Languages, Einzelquellen-Health (GET /{id}/health), Aufgaben-Badge (GET /tasks/summary), Promote, Discovery, PDF-Upload, KI-Vorschläge, Klassifikation"
|
||||||
dashboard.py: "Aggregat-Endpoints für Übersichts-Tab"
|
dashboard.py: "Aggregat-Endpoints für Übersichts-Tab"
|
||||||
token_usage.py: "Token-Verbrauch pro Org/Monat, Credits-Stellwerte je Lizenz (PUT /budget). Laufzeitfelder (credits_used-Reset, credits_carried, credits_period_start, budget_warning_sent) verwaltet der MONITOR, das Portal setzt nur Stellwerte"
|
token_usage.py: "Token-Verbrauch pro Org/Monat, Credits-Stellwerte je Lizenz (PUT /budget). Laufzeitfelder (credits_used-Reset, credits_carried, credits_period_start, budget_warning_sent) verwaltet der MONITOR, das Portal setzt nur Stellwerte"
|
||||||
pricing.py: "Preis-Empfehlung aus Ist-Kosten (GET /cost-basis) und pflegbare Preistabelle billing_tariff (GET/PUT /tariff). Die Tabelle ist die Quelle der Buchung im Monitor, der Rechner kann sie speichern"
|
pricing.py: "Preis-Empfehlung aus Ist-Kosten (GET /cost-basis) und pflegbare Preistabelle billing_tariff (GET/PUT /tariff). Die Tabelle ist die Quelle der Buchung im Monitor, der Rechner kann sie speichern"
|
||||||
@@ -61,15 +61,15 @@ src/:
|
|||||||
|
|
||||||
static/:
|
static/:
|
||||||
index.html: "Login (Passwort)"
|
index.html: "Login (Passwort)"
|
||||||
dashboard.html: "Hauptdashboard mit Tabs (Dashboard, Orgs, Lizenzen, Quellen, Audit)"
|
dashboard.html: "Hauptdashboard mit Tabs (Dashboard, Orgs, Quellen, Audit). Quellen hat 3 Unterreiter (Quellenliste, Aufgaben, X-Recherche-Konten)"
|
||||||
favicon.svg: "AegisSight Logo"
|
favicon.svg: "AegisSight Logo"
|
||||||
css/: "Stylesheets (Dark Theme)"
|
css/: "Stylesheets (Dark Theme)"
|
||||||
js/:
|
js/:
|
||||||
app.js: "Hauptlogik, Login, Tab-Switching, Dashboard-Render, ThemeManager (Dark/Light, localStorage portal_theme)"
|
app.js: "Hauptlogik, Login, Tab-Switching, Dashboard-Render, ThemeManager (Dark/Light, localStorage portal_theme)"
|
||||||
a11y.js: "Barrierefreiheits-Panel (Kontrast, Focus, Schrift, Animationen). IDENTISCHE Datei wie im Monitor-Repo, bei Aenderungen dort mitziehen"
|
a11y.js: "Barrierefreiheits-Panel (Kontrast, Focus, Schrift, Animationen). IDENTISCHE Datei wie im Monitor-Repo, bei Aenderungen dort mitziehen"
|
||||||
statistik.js: "Statistik-Tab, selbst-injizierend wie rechner.js. Inline-SVG-Charts (keine Bibliothek), Serienfarben mit dem dataviz-Validator gegen beide Themes geprüft"
|
statistik.js: "Statistik-Tab, selbst-injizierend wie rechner.js. Inline-SVG-Charts (keine Bibliothek), Serienfarben mit dem dataviz-Validator gegen beide Themes geprüft"
|
||||||
sources.js: "Grundquellen + Kundenquellen Management"
|
sources.js: "Vereinte Quellenliste (Grund- + Kundenquellen, Herkunfts-Filter, Health-Ausklapp mit Deaktivieren, Bulk-Promote, Modal, Discovery, PDF-Upload)"
|
||||||
source-health.js: "Quellen-Health & KI-Vorschläge"
|
aufgaben.js: "Aufgaben-Posteingang (Health-Vorschläge + Klassifikations-Review + Verlauf, Badge via GET /tasks/summary)"
|
||||||
audit.js: "Audit-Log Tab"
|
audit.js: "Audit-Log Tab"
|
||||||
|
|
||||||
migrations/:
|
migrations/:
|
||||||
|
|||||||
@@ -112,6 +112,39 @@ async def get_sources_meta(admin: dict = Depends(get_current_admin)):
|
|||||||
return get_meta()
|
return get_meta()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/summary")
|
||||||
|
async def tasks_summary(
|
||||||
|
admin: dict = Depends(get_current_admin),
|
||||||
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
|
):
|
||||||
|
"""Billiger Zähler für den Aufgaben-Badge im Quellen-Reiter.
|
||||||
|
|
||||||
|
classification_pending nutzt DIESELBE Bedingung wie /classification/stats,
|
||||||
|
damit Badge und Review-Karten nie auseinanderlaufen.
|
||||||
|
"""
|
||||||
|
suggestions_pending = 0
|
||||||
|
cur = await db.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='source_suggestions'"
|
||||||
|
)
|
||||||
|
if await cur.fetchone():
|
||||||
|
cur = await db.execute(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM source_suggestions WHERE status = 'pending'"
|
||||||
|
)
|
||||||
|
suggestions_pending = (await cur.fetchone())["cnt"]
|
||||||
|
|
||||||
|
cur = await db.execute(
|
||||||
|
"""SELECT COUNT(*) AS cnt FROM sources
|
||||||
|
WHERE status = 'active' AND proposed_political_orientation IS NOT NULL"""
|
||||||
|
)
|
||||||
|
classification_pending = (await cur.fetchone())["cnt"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"suggestions_pending": suggestions_pending,
|
||||||
|
"classification_pending": classification_pending,
|
||||||
|
"total": suggestions_pending + classification_pending,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class GlobalSourceCreate(BaseModel):
|
class GlobalSourceCreate(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=200)
|
name: str = Field(min_length=1, max_length=200)
|
||||||
|
|||||||
@@ -286,8 +286,7 @@
|
|||||||
<div class="section" id="sec-sources">
|
<div class="section" id="sec-sources">
|
||||||
<div class="nav-tabs" id="sourceSubTabs">
|
<div class="nav-tabs" id="sourceSubTabs">
|
||||||
<button class="nav-tab active" data-subtab="global-sources">Quellenliste</button>
|
<button class="nav-tab active" data-subtab="global-sources">Quellenliste</button>
|
||||||
<button class="nav-tab" data-subtab="source-health">Quellen-Health</button>
|
<button class="nav-tab" data-subtab="aufgaben">Aufgaben <span class="sources-tab-badge" id="tasksPendingBadge">0</span></button>
|
||||||
<button class="nav-tab" data-subtab="classification-review">Klassifikation <span class="sources-tab-badge" id="classificationPendingBadge">0</span></button>
|
|
||||||
<button class="nav-tab" data-subtab="x-scraper">X-Recherche-Konten</button>
|
<button class="nav-tab" data-subtab="x-scraper">X-Recherche-Konten</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -350,26 +349,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quellen-Health (Sub-Tab) - drei Bereiche als Sub-Sub-Tabs;
|
<!-- Aufgaben-Posteingang: Health-Vorschläge + Klassifikations-Review + Verlauf -->
|
||||||
source-health.js rendert pro Bereich in den jeweiligen Container. -->
|
<div class="section" id="sub-aufgaben">
|
||||||
<div class="section" id="sub-source-health">
|
|
||||||
<div class="nav-tabs" id="healthSubTabs" style="margin-top:0;">
|
|
||||||
<button class="nav-tab active" data-healthtab="suggestions">Vorschläge</button>
|
|
||||||
<button class="nav-tab" data-healthtab="checks">Health-Status</button>
|
|
||||||
<button class="nav-tab" data-healthtab="verlauf">Verlauf</button>
|
|
||||||
</div>
|
|
||||||
<div id="ht-suggestions" class="health-pane active"></div>
|
|
||||||
<div id="ht-checks" class="health-pane" style="display:none;"></div>
|
|
||||||
<div id="ht-verlauf" class="health-pane" style="display:none;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Klassifikations-Review -->
|
|
||||||
<div class="section" id="sub-classification-review">
|
|
||||||
<div class="action-bar review-toolbar">
|
<div class="action-bar review-toolbar">
|
||||||
<div class="review-toolbar-info">
|
<div class="review-toolbar-info">
|
||||||
<span><strong id="reviewPendingCount">0</strong> Vorschläge ausstehend</span>
|
<span><strong id="aufgabenSuggestionCount">0</strong> Vorschläge offen</span>
|
||||||
|
<span><strong id="reviewPendingCount">0</strong> Klassifikationen ausstehend</span>
|
||||||
<label class="review-conf-filter">
|
<label class="review-conf-filter">
|
||||||
Mindest-Konfidenz:
|
Mindest-Konfidenz
|
||||||
<select class="filter-select" id="reviewMinConfidence" onchange="loadClassificationQueue()">
|
<select class="filter-select" id="reviewMinConfidence" onchange="loadClassificationQueue()">
|
||||||
<option value="0">alle</option>
|
<option value="0">alle</option>
|
||||||
<option value="0.5">0.5+</option>
|
<option value="0.5">0.5+</option>
|
||||||
@@ -385,11 +372,14 @@
|
|||||||
<button class="btn btn-primary btn-small" onclick="bulkApproveHighConfidence()" title="Alle Vorschläge ab 0.85 Konfidenz übernehmen">Alle ≥ 0.85 genehmigen</button>
|
<button class="btn btn-primary btn-small" onclick="bulkApproveHighConfidence()" title="Alle Vorschläge ab 0.85 Konfidenz übernehmen">Alle ≥ 0.85 genehmigen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div id="aufgabenSuggestions"></div>
|
||||||
|
<div class="card" style="margin-bottom:16px;">
|
||||||
|
<div class="card-header"><h2>Klassifikations-Review</h2></div>
|
||||||
<div class="review-list" id="classificationReviewList">
|
<div class="review-list" id="classificationReviewList">
|
||||||
<div class="text-muted" style="padding:24px;text-align:center;">Lade Review-Queue…</div>
|
<div class="text-muted" style="padding:24px;text-align:center;">Lade Review-Queue…</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="aufgabenVerlauf"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- X-Recherche-Konten (Sub-Tab) -->
|
<!-- X-Recherche-Konten (Sub-Tab) -->
|
||||||
@@ -1015,10 +1005,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/a11y.js?v=20260725a"></script>
|
<script src="/static/js/a11y.js?v=20260725a"></script>
|
||||||
<script src="/static/js/app.js?v=20260725r"></script>
|
<script src="/static/js/app.js?v=20260725s"></script>
|
||||||
<script src="/static/js/sources.js?v=20260725b"></script>
|
<script src="/static/js/sources.js?v=20260725c"></script>
|
||||||
|
<script src="/static/js/aufgaben.js?v=20260725a"></script>
|
||||||
<script src="/static/js/x-scraper.js?v=20260522a"></script>
|
<script src="/static/js/x-scraper.js?v=20260522a"></script>
|
||||||
<script src="/static/js/source-health.js?v=20260725b"></script>
|
|
||||||
<script src="/static/js/audit.js?v=20260509d"></script>
|
<script src="/static/js/audit.js?v=20260509d"></script>
|
||||||
<div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div>
|
<div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -146,10 +146,10 @@ function logout() {
|
|||||||
|
|
||||||
// --- Navigation ---
|
// --- Navigation ---
|
||||||
function setupNavTabs() {
|
function setupNavTabs() {
|
||||||
document.querySelectorAll(".nav-tabs:not(#orgDetailTabs):not(#sourceSubTabs):not(#healthSubTabs) .nav-tab").forEach(tab => {
|
document.querySelectorAll(".nav-tabs:not(#orgDetailTabs):not(#sourceSubTabs) .nav-tab").forEach(tab => {
|
||||||
tab.addEventListener("click", () => {
|
tab.addEventListener("click", () => {
|
||||||
const section = tab.dataset.section;
|
const section = tab.dataset.section;
|
||||||
document.querySelectorAll(".nav-tabs:not(#orgDetailTabs):not(#sourceSubTabs):not(#healthSubTabs) .nav-tab").forEach(t => t.classList.remove("active"));
|
document.querySelectorAll(".nav-tabs:not(#orgDetailTabs):not(#sourceSubTabs) .nav-tab").forEach(t => t.classList.remove("active"));
|
||||||
tab.classList.add("active");
|
tab.classList.add("active");
|
||||||
document.querySelectorAll(".app-content > .section").forEach(s => s.classList.remove("active"));
|
document.querySelectorAll(".app-content > .section").forEach(s => s.classList.remove("active"));
|
||||||
document.getElementById(`sec-${section}`).classList.add("active");
|
document.getElementById(`sec-${section}`).classList.add("active");
|
||||||
@@ -649,7 +649,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
function switchToOrg(orgId) {
|
function switchToOrg(orgId) {
|
||||||
// Switch to orgs tab and open detail
|
// Switch to orgs tab and open detail
|
||||||
document.querySelectorAll(".nav-tabs:not(#orgDetailTabs):not(#sourceSubTabs):not(#healthSubTabs) .nav-tab").forEach(t => t.classList.remove("active"));
|
document.querySelectorAll(".nav-tabs:not(#orgDetailTabs):not(#sourceSubTabs) .nav-tab").forEach(t => t.classList.remove("active"));
|
||||||
document.querySelector('.nav-tab[data-section="orgs"]').classList.add("active");
|
document.querySelector('.nav-tab[data-section="orgs"]').classList.add("active");
|
||||||
document.querySelectorAll(".app-content > .section").forEach(s => s.classList.remove("active"));
|
document.querySelectorAll(".app-content > .section").forEach(s => s.classList.remove("active"));
|
||||||
document.getElementById("sec-orgs").classList.add("active");
|
document.getElementById("sec-orgs").classList.add("active");
|
||||||
|
|||||||
412
src/static/js/aufgaben.js
Normale Datei
412
src/static/js/aufgaben.js
Normale Datei
@@ -0,0 +1,412 @@
|
|||||||
|
/* Aufgaben-Posteingang: Health-Vorschläge + Klassifikations-Review + Verlauf */
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
let suggestionsCache = [];
|
||||||
|
let healthHistoryCache = [];
|
||||||
|
|
||||||
|
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: '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;"><polyline points="20 6 9 17 4 12"/></svg>',
|
||||||
|
x: '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
|
||||||
|
search:'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Badge (Summe offene Vorschläge + ausstehende Klassifikationen) ---
|
||||||
|
async function refreshTasksBadge() {
|
||||||
|
try {
|
||||||
|
const s = await API.get("/api/sources/tasks/summary");
|
||||||
|
const badge = document.getElementById("tasksPendingBadge");
|
||||||
|
if (badge) badge.textContent = String(s.total || 0);
|
||||||
|
} catch (_) { /* Badge ist nicht kritisch */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Aufgaben laden ---
|
||||||
|
async function loadAufgaben() {
|
||||||
|
try {
|
||||||
|
const [suggestions, history] = await Promise.all([
|
||||||
|
API.get("/api/sources/suggestions"),
|
||||||
|
API.get("/api/sources/health/history?limit=10").catch(() => []),
|
||||||
|
]);
|
||||||
|
suggestionsCache = suggestions || [];
|
||||||
|
healthHistoryCache = history || [];
|
||||||
|
renderAufgabenSuggestions();
|
||||||
|
renderAufgabenVerlauf();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Aufgaben laden fehlgeschlagen:", err);
|
||||||
|
}
|
||||||
|
loadClassificationQueue();
|
||||||
|
refreshTasksBadge();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAufgabenSuggestions() {
|
||||||
|
const pane = document.getElementById("aufgabenSuggestions");
|
||||||
|
if (!pane) return;
|
||||||
|
const pending = suggestionsCache.filter((s) => s.status === "pending");
|
||||||
|
const countEl = document.getElementById("aufgabenSuggestionCount");
|
||||||
|
if (countEl) countEl.textContent = String(pending.length);
|
||||||
|
|
||||||
|
if (pending.length === 0) {
|
||||||
|
pane.innerHTML = `
|
||||||
|
<div class="card" style="margin-bottom:16px;">
|
||||||
|
<div class="card-header"><h2>Vorschläge</h2></div>
|
||||||
|
<div class="card-body text-muted">Keine offenen Vorschläge vorhanden.</div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pane.innerHTML = `
|
||||||
|
<div class="card" style="margin-bottom:16px;">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>Vorschläge (${pending.length} offen)</h2>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>Beschreibung</th>
|
||||||
|
<th>Priorität</th>
|
||||||
|
<th>Erstellt</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${pending
|
||||||
|
.map(
|
||||||
|
(s) => `
|
||||||
|
<tr>
|
||||||
|
<td><span class="badge badge-suggestion-${s.suggestion_type}">${SUGGESTION_TYPE_LABELS[s.suggestion_type] || s.suggestion_type}</span></td>
|
||||||
|
<td>${esc(s.title)}</td>
|
||||||
|
<td class="text-secondary" style="max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="${esc(s.description || "")}">${esc(s.description || "")}</td>
|
||||||
|
<td><span class="badge badge-priority-${s.priority}">${PRIORITY_LABELS[s.priority] || s.priority}</span></td>
|
||||||
|
<td class="text-secondary">${formatDateTime(s.created_at)}</td>
|
||||||
|
<td style="white-space:nowrap;">
|
||||||
|
${s.suggestion_type === "deactivate_source" && s.source_id ? `<button class="btn btn-secondary btn-small" data-source-id="${s.source_id}" data-source-name="${esc(s.title.split(':')[0] || s.title)}" onclick="searchFix(this)" title="Lösung suchen">${LUCIDE_ICONS.search}</button> ` : ""}
|
||||||
|
<button class="btn btn-success btn-small" onclick="handleSuggestion(${s.id}, true)" title="Annehmen">${LUCIDE_ICONS.check}</button>
|
||||||
|
<button class="btn btn-danger btn-small" onclick="handleSuggestion(${s.id}, false)" title="Ablehnen">${LUCIDE_ICONS.x}</button>
|
||||||
|
</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAufgabenVerlauf() {
|
||||||
|
const pane = document.getElementById("aufgabenVerlauf");
|
||||||
|
if (!pane) return;
|
||||||
|
const recent = suggestionsCache.filter((s) => s.status !== "pending");
|
||||||
|
|
||||||
|
let html = "";
|
||||||
|
if (recent.length > 0) {
|
||||||
|
const shown = recent.slice(0, 20);
|
||||||
|
html += `
|
||||||
|
<details class="card" style="margin-bottom:16px;">
|
||||||
|
<summary style="cursor:pointer; padding:14px 18px; list-style:none;">
|
||||||
|
<span style="color:var(--accent, #C8A851); font-weight:600; font-size:1.02rem;">Erledigte Vorschläge</span>
|
||||||
|
<span class="text-secondary" style="font-size:13px; margin-left:8px;">(${recent.length} Einträge, klick zum Aufklappen)</span>
|
||||||
|
</summary>
|
||||||
|
<div class="table-wrap" style="border-top:1px solid var(--border, rgba(255,255,255,0.08));">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Typ</th><th>Titel</th><th>Status</th><th>Bearbeitet</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${shown
|
||||||
|
.map(
|
||||||
|
(s) => `
|
||||||
|
<tr>
|
||||||
|
<td><span class="badge badge-suggestion-${s.suggestion_type}">${SUGGESTION_TYPE_LABELS[s.suggestion_type] || s.suggestion_type}</span></td>
|
||||||
|
<td>${esc(s.title)}</td>
|
||||||
|
<td><span class="badge badge-${s.status === "accepted" ? "active" : "inactive"}">${s.status === "accepted" ? "Angenommen" : "Abgelehnt"}</span></td>
|
||||||
|
<td class="text-secondary">${formatDateTime(s.reviewed_at)}</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (healthHistoryCache.length > 0) {
|
||||||
|
html += `
|
||||||
|
<details class="card" style="margin-bottom:16px;">
|
||||||
|
<summary style="cursor:pointer; padding:14px 18px; list-style:none;">
|
||||||
|
<span style="color:var(--accent, #C8A851); font-weight:600; font-size:1.02rem;">Letzte Prüfläufe</span>
|
||||||
|
<span class="text-secondary" style="font-size:13px; margin-left:8px;">(${healthHistoryCache.length} Läufe, klick zum Aufklappen)</span>
|
||||||
|
</summary>
|
||||||
|
<div class="table-wrap" style="border-top:1px solid var(--border, rgba(255,255,255,0.08));">
|
||||||
|
<table style="table-layout:fixed; width:100%;">
|
||||||
|
<colgroup>
|
||||||
|
<col style="width:200px;">
|
||||||
|
<col style="width:160px;">
|
||||||
|
<col style="width:110px;">
|
||||||
|
<col style="width:130px;">
|
||||||
|
<col style="width:110px;">
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Zeitpunkt</th>
|
||||||
|
<th>Run-ID</th>
|
||||||
|
<th style="text-align:center;">Fehler</th>
|
||||||
|
<th style="text-align:center;">Warnungen</th>
|
||||||
|
<th style="text-align:center;">OK</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${healthHistoryCache.map(r => `
|
||||||
|
<tr>
|
||||||
|
<td>${formatDateTime(r.archived_at)}</td>
|
||||||
|
<td class="text-secondary" style="font-size:12px;" title="${esc(r.run_id)}"><code>${esc(String(r.run_id || "").slice(0, 12))}</code></td>
|
||||||
|
<td class="text-danger" style="text-align:center;">${r.errors || 0}</td>
|
||||||
|
<td class="text-warning" style="text-align:center;">${r.warnings || 0}</td>
|
||||||
|
<td class="text-success" style="text-align:center;">${r.ok || 0}</td>
|
||||||
|
</tr>`).join("")}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
pane.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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");
|
||||||
|
}
|
||||||
|
loadAufgaben();
|
||||||
|
// Quellenliste auch aktualisieren
|
||||||
|
if (typeof loadUnifiedSources === "function") loadUnifiedSources();
|
||||||
|
} catch (err) {
|
||||||
|
showToast("Fehler. " + err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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");
|
||||||
|
loadAufgaben();
|
||||||
|
} catch (err) {
|
||||||
|
showToast("Fehler. " + err.message, "error");
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "Lösung suchen";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Klassifikations-Review ===
|
||||||
|
|
||||||
|
const POLITICAL_LABELS = {
|
||||||
|
links_extrem: { short: "L+", full: "Links (extrem)" },
|
||||||
|
links: { short: "L", full: "Links" },
|
||||||
|
mitte_links: { short: "ML", full: "Mitte-Links" },
|
||||||
|
liberal: { short: "LIB", full: "Liberal" },
|
||||||
|
mitte: { short: "M", full: "Mitte" },
|
||||||
|
konservativ: { short: "KON", full: "Konservativ" },
|
||||||
|
mitte_rechts: { short: "MR", full: "Mitte-Rechts" },
|
||||||
|
rechts: { short: "R", full: "Rechts" },
|
||||||
|
rechts_extrem: { short: "R+", full: "Rechts (extrem)" },
|
||||||
|
na: { short: "?", full: "Nicht eingeordnet" },
|
||||||
|
};
|
||||||
|
const RELIABILITY_LABELS = {
|
||||||
|
sehr_hoch: "Sehr hoch", hoch: "Hoch", gemischt: "Gemischt",
|
||||||
|
niedrig: "Niedrig", sehr_niedrig: "Sehr niedrig", na: "Nicht eingeordnet",
|
||||||
|
};
|
||||||
|
const MEDIA_TYPE_LABELS = {
|
||||||
|
tageszeitung: "Tageszeitung", wochenzeitung: "Wochenzeitung", magazin: "Magazin",
|
||||||
|
tv_sender: "TV-Sender", radio: "Radio", oeffentlich_rechtlich: "Öffentlich-Rechtlich",
|
||||||
|
nachrichtenagentur: "Nachrichtenagentur", online_only: "Online-only", blog: "Blog",
|
||||||
|
telegram_kanal: "Telegram-Kanal", telegram_bot: "Telegram-Bot", podcast: "Podcast",
|
||||||
|
social_media: "Social Media", imageboard: "Imageboard", think_tank: "Think Tank",
|
||||||
|
ngo: "NGO", behoerde: "Behörde", staatsmedium: "Staatsmedium",
|
||||||
|
fachmedium: "Fachmedium", sonstige: "Sonstige",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadClassificationQueue() {
|
||||||
|
const list = document.getElementById("classificationReviewList");
|
||||||
|
if (!list) return;
|
||||||
|
const minConf = parseFloat(document.getElementById("reviewMinConfidence")?.value || "0");
|
||||||
|
list.innerHTML = '<div class="text-muted" style="padding:24px;text-align:center;">Lade…</div>';
|
||||||
|
try {
|
||||||
|
const items = await API.get(`/api/sources/classification/queue?limit=200&min_confidence=${minConf}`);
|
||||||
|
const countEl = document.getElementById("reviewPendingCount");
|
||||||
|
if (countEl) countEl.textContent = String(items.length);
|
||||||
|
if (items.length === 0) {
|
||||||
|
list.innerHTML = '<div class="text-muted" style="padding:24px;text-align:center;">Keine ausstehenden Klassifikationen.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = items.map((it) => renderClassificationQueueItem(it)).join("");
|
||||||
|
} catch (err) {
|
||||||
|
list.innerHTML = `<div class="text-danger" style="padding:24px;text-align:center;">Fehler. ${esc(err.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderClassificationQueueItem(item) {
|
||||||
|
const cur = item.current || {};
|
||||||
|
const prop = item.proposed || {};
|
||||||
|
const conf = prop.confidence || 0;
|
||||||
|
const confPct = Math.round(conf * 100);
|
||||||
|
const confClass = conf >= 0.85 ? "high" : conf >= 0.7 ? "medium" : "low";
|
||||||
|
|
||||||
|
const polFmt = (v) => (v && v !== "na" ? POLITICAL_LABELS[v]?.full || v : "–");
|
||||||
|
const mtFmt = (v) => (v ? MEDIA_TYPE_LABELS[v] || v : "–");
|
||||||
|
const relFmt = (v) => (v && v !== "na" ? RELIABILITY_LABELS[v] || v : "–");
|
||||||
|
const stateFmt = (v) => (v ? "ja" : "nein");
|
||||||
|
const ccFmt = (v) => v || "–";
|
||||||
|
|
||||||
|
const row = (label, c, p, fmt) => {
|
||||||
|
const cs = fmt(c);
|
||||||
|
const ps = fmt(p);
|
||||||
|
const changed = cs !== ps;
|
||||||
|
return `<div class="review-diff-row${changed ? " changed" : ""}">
|
||||||
|
<span class="review-diff-label">${esc(label)}</span>
|
||||||
|
<span class="review-diff-current">${esc(cs)}</span>
|
||||||
|
<span class="review-diff-arrow">→</span>
|
||||||
|
<span class="review-diff-proposed">${esc(ps)}</span>
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const reasoning = prop.reasoning ? esc(prop.reasoning) : "";
|
||||||
|
|
||||||
|
return `<div class="review-card" data-source-id="${item.id}">
|
||||||
|
<div class="review-card-header">
|
||||||
|
<div class="review-card-title">
|
||||||
|
<span class="review-card-name">${esc(item.name)}</span>
|
||||||
|
${item.is_global ? '<span class="review-global-badge">Grundquelle</span>' : ""}
|
||||||
|
<span class="review-card-domain">${esc(item.domain || "")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="review-card-confidence conf-${confClass}" title="LLM-Konfidenz">
|
||||||
|
<span class="conf-value">${confPct}%</span>
|
||||||
|
<span class="conf-label">Konfidenz</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="review-card-diff">
|
||||||
|
${row("Politik", cur.political_orientation, prop.political_orientation, polFmt)}
|
||||||
|
${row("Medientyp", cur.media_type, prop.media_type, mtFmt)}
|
||||||
|
${row("Glaubwürdigkeit", cur.reliability, prop.reliability, relFmt)}
|
||||||
|
${row("Staatsnah", cur.state_affiliated, prop.state_affiliated, stateFmt)}
|
||||||
|
${row("Land", cur.country_code, prop.country_code, ccFmt)}
|
||||||
|
</div>
|
||||||
|
${reasoning ? `<div class="review-card-reasoning"><strong>Begründung.</strong> ${reasoning}</div>` : ""}
|
||||||
|
<div class="review-card-actions">
|
||||||
|
<button class="btn btn-small btn-primary" onclick="approveClassification(${item.id})">Übernehmen</button>
|
||||||
|
<button class="btn btn-small btn-secondary" onclick="rejectClassification(${item.id})">Verwerfen</button>
|
||||||
|
<button class="btn btn-small btn-secondary" data-reclassify-id="${item.id}" onclick="reclassifySource(${item.id})">Neu klassifizieren</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approveClassification(id) {
|
||||||
|
try {
|
||||||
|
await API.post(`/api/sources/${id}/classification/approve`, {});
|
||||||
|
showToast("Klassifikation übernommen.", "success");
|
||||||
|
loadClassificationQueue();
|
||||||
|
refreshTasksBadge();
|
||||||
|
} catch (err) {
|
||||||
|
showToast("Approve fehlgeschlagen. " + err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejectClassification(id) {
|
||||||
|
try {
|
||||||
|
await API.post(`/api/sources/${id}/classification/reject`, {});
|
||||||
|
showToast("Vorschlag verworfen.", "success");
|
||||||
|
loadClassificationQueue();
|
||||||
|
refreshTasksBadge();
|
||||||
|
} catch (err) {
|
||||||
|
showToast("Reject fehlgeschlagen. " + err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reclassifySource(id) {
|
||||||
|
const btn = document.querySelector(`[data-reclassify-id="${id}"]`);
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = "..."; }
|
||||||
|
try {
|
||||||
|
await API.post(`/api/sources/${id}/classification/reclassify`, {});
|
||||||
|
showToast("Neu klassifiziert.", "success");
|
||||||
|
loadClassificationQueue();
|
||||||
|
refreshTasksBadge();
|
||||||
|
} catch (err) {
|
||||||
|
showToast("Reclassify fehlgeschlagen. " + err.message, "error");
|
||||||
|
} finally {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = "Neu klassifizieren"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerBulkClassify() {
|
||||||
|
if (!confirm("Bulk-Klassifikation aller noch nicht klassifizierten Quellen starten? Läuft im Hintergrund (~3-5 Sek pro Quelle, ~0.02 USD pro Quelle).")) return;
|
||||||
|
try {
|
||||||
|
const r = await API.post("/api/sources/classification/bulk-classify?limit=500&only_unclassified=true", {});
|
||||||
|
showToast(`Bulk-Klassifikation gestartet (limit=${r.limit}). In ~10 min neu laden.`, "info");
|
||||||
|
} catch (err) {
|
||||||
|
showToast("Start fehlgeschlagen. " + err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkApproveHighConfidence() {
|
||||||
|
if (!confirm("Alle Vorschläge mit Konfidenz ≥ 0.85 genehmigen?")) return;
|
||||||
|
try {
|
||||||
|
const r = await API.post("/api/sources/classification/bulk-approve?min_confidence=0.85", {});
|
||||||
|
showToast(`${r.approved} Vorschläge übernommen.`, "success");
|
||||||
|
loadClassificationQueue();
|
||||||
|
refreshTasksBadge();
|
||||||
|
} catch (err) {
|
||||||
|
showToast("Bulk-Approve fehlgeschlagen. " + err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerExternalReputationSync() {
|
||||||
|
if (!confirm("IFCN- und EUvsDisinfo-Datenbanken jetzt syncen? Läuft im Hintergrund (~30 Sek).")) return;
|
||||||
|
try {
|
||||||
|
await API.post("/api/sources/external-reputation/sync", {});
|
||||||
|
showToast("Externer Sync gestartet. Quellenliste in ~30 Sek neu laden.", "info");
|
||||||
|
} catch (err) {
|
||||||
|
showToast("Sync fehlgeschlagen. " + err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,455 +0,0 @@
|
|||||||
/* 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;
|
|
||||||
|
|
||||||
|
|
||||||
// CHECK_TYPE_LABELS kommt global aus sources.js (lädt vor dieser Datei)
|
|
||||||
|
|
||||||
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: '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;"><polyline points="20 6 9 17 4 12"/></svg>',
|
|
||||||
x: '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
|
|
||||||
search:'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
|
|
||||||
refresh:'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>',
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Init ---
|
|
||||||
// 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", () => {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 = `
|
|
||||||
<div class="card" style="margin-bottom:16px;">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>Vorschläge (${pendingSuggestions.length} offen)</h2>
|
|
||||||
</div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Typ</th>
|
|
||||||
<th>Titel</th>
|
|
||||||
<th>Beschreibung</th>
|
|
||||||
<th>Priorität</th>
|
|
||||||
<th>Erstellt</th>
|
|
||||||
<th>Aktionen</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${pendingSuggestions
|
|
||||||
.map(
|
|
||||||
(s) => `
|
|
||||||
<tr>
|
|
||||||
<td><span class="badge badge-suggestion-${s.suggestion_type}">${SUGGESTION_TYPE_LABELS[s.suggestion_type] || s.suggestion_type}</span></td>
|
|
||||||
<td>${esc(s.title)}</td>
|
|
||||||
<td class="text-secondary" style="max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="${esc(s.description || "")}">${esc(s.description || "")}</td>
|
|
||||||
<td><span class="badge badge-priority-${s.priority}">${PRIORITY_LABELS[s.priority] || s.priority}</span></td>
|
|
||||||
<td class="text-secondary">${formatDateTime(s.created_at)}</td>
|
|
||||||
<td style="white-space:nowrap;">
|
|
||||||
${s.suggestion_type === "deactivate_source" && s.source_id ? `<button class="btn btn-secondary btn-small" data-source-id="${s.source_id}" data-source-name="${esc(s.title.split(':')[0] || s.title)}" onclick="searchFix(this)" title="Lösung suchen">${LUCIDE_ICONS.search}</button> ` : ""}
|
|
||||||
<button class="btn btn-success btn-small" onclick="handleSuggestion(${s.id}, true)" title="Annehmen">${LUCIDE_ICONS.check}</button>
|
|
||||||
<button class="btn btn-danger btn-small" onclick="handleSuggestion(${s.id}, false)" title="Ablehnen">${LUCIDE_ICONS.x}</button>
|
|
||||||
</td>
|
|
||||||
</tr>`,
|
|
||||||
)
|
|
||||||
.join("")}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
} else {
|
|
||||||
suggestionsHtml = `
|
|
||||||
<div class="card" style="margin-bottom:16px;">
|
|
||||||
<div class="card-header"><h2>Vorschläge</h2></div>
|
|
||||||
<div class="card-body text-muted">Keine offenen Vorschläge vorhanden.</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vergangene Vorschläge - eingeklappt by default, weil rein historisch.
|
|
||||||
let historyHtml = "";
|
|
||||||
if (recentSuggestions.length > 0) {
|
|
||||||
const shown = recentSuggestions.slice(0, 20);
|
|
||||||
historyHtml = `
|
|
||||||
<details class="card" style="margin-bottom:16px;">
|
|
||||||
<summary style="cursor:pointer; padding:14px 18px; list-style:none;">
|
|
||||||
<span style="color:var(--accent, #C8A851); font-weight:600; font-size:1.02rem;">Verlauf</span>
|
|
||||||
<span class="text-secondary" style="font-size:13px; margin-left:8px;">(${recentSuggestions.length} erledigte Vorschläge - klick zum Aufklappen)</span>
|
|
||||||
</summary>
|
|
||||||
<div class="table-wrap" style="border-top:1px solid var(--border, rgba(255,255,255,0.08));">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Typ</th><th>Titel</th><th>Status</th><th>Bearbeitet</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${shown
|
|
||||||
.map(
|
|
||||||
(s) => `
|
|
||||||
<tr>
|
|
||||||
<td><span class="badge badge-suggestion-${s.suggestion_type}">${SUGGESTION_TYPE_LABELS[s.suggestion_type] || s.suggestion_type}</span></td>
|
|
||||||
<td>${esc(s.title)}</td>
|
|
||||||
<td><span class="badge badge-${s.status === "accepted" ? "active" : "inactive"}">${s.status === "accepted" ? "Angenommen" : "Abgelehnt"}</span></td>
|
|
||||||
<td class="text-secondary">${formatDateTime(s.reviewed_at)}</td>
|
|
||||||
</tr>`,
|
|
||||||
)
|
|
||||||
.join("")}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</details>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 `<span class="${cssClass}" title="${esc(detail)}">${total} ${label}</span> <span class="text-secondary" style="font-size:11px;">(${esc(detail)})</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
healthHtml = `
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>Health-Check Ergebnisse</h2>
|
|
||||||
<span class="text-secondary" style="font-size:13px;">
|
|
||||||
Letzter Check: ${healthData.last_check ? formatDateTime(healthData.last_check) : "Noch nie"}
|
|
||||||
|
|
|
||||||
${breakdownLine("error", "text-danger") || `<span class="text-danger">0 Fehler</span>`}
|
|
||||||
${breakdownLine("warning", "text-warning") || `<span class="text-warning">0 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="issues" ${healthFilters.status === "issues" ? "selected" : ""}>Nur Probleme (Default)</option>
|
|
||||||
<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} angezeigt${totalAll > allChecks.length ? ` (von ${totalAll} insgesamt)` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
if (filtered.length > 0) {
|
|
||||||
healthHtml += `
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Quelle</th><th>Typ</th><th>Org</th><th>Status</th><th>Details</th><th>Aktion</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${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 `
|
|
||||||
<tr>
|
|
||||||
<td${nameTip}>${esc(c.name)}</td>
|
|
||||||
<td>${CHECK_TYPE_LABELS[c.check_type] || c.check_type}</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><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:300px;" title="${esc(c.message || "")}">${esc(c.message || "")}</td>
|
|
||||||
<td>${(
|
|
||||||
(c.status === "error" && c.check_type === "reachability") ||
|
|
||||||
(c.status === "warning" && c.check_type === "feed_validity")
|
|
||||||
) ? `<button class="btn btn-secondary btn-small" data-source-id="${c.source_id}" data-source-name="${esc(c.name)}" onclick="searchFix(this)" title="Lösung suchen">${LUCIDE_ICONS.search}</button>` : ""}</td>
|
|
||||||
</tr>`;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.join("")}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>`;
|
|
||||||
} 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 += `
|
|
||||||
<div class="card-body text-muted">
|
|
||||||
Keine Treffer in den geladenen ${allChecks.length} von ${totalAll} Items mit dem aktuellen Filter.
|
|
||||||
<a href="#" onclick="event.preventDefault(); loadAllHealth()" style="text-decoration:underline;">
|
|
||||||
Alle ${totalAll} Health-Checks laden
|
|
||||||
</a> und Filter erneut anwenden.
|
|
||||||
</div>`;
|
|
||||||
} else {
|
|
||||||
healthHtml += '<div class="card-body text-muted">Keine Ergebnisse mit diesen Filtern.</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Footer mit Mehr-laden-Buttons, falls Backend has_more meldet
|
|
||||||
if (hasMore) {
|
|
||||||
const remaining = Math.max(0, totalAll - allChecks.length);
|
|
||||||
healthHtml += `
|
|
||||||
<div class="card-body" style="display:flex;justify-content:center;gap:10px;align-items:center;border-top:1px solid var(--border, rgba(255,255,255,0.08));">
|
|
||||||
<span class="text-secondary" style="font-size:13px;">${allChecks.length} von ${totalAll} geladen</span>
|
|
||||||
<button class="btn btn-secondary btn-small" onclick="loadMoreHealth()">+200 laden</button>
|
|
||||||
<button class="btn btn-secondary btn-small" onclick="loadAllHealth()">Alle ${remaining} weiteren laden</button>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
healthHtml += "</div>";
|
|
||||||
} else {
|
|
||||||
healthHtml = `
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><h2>Health-Check Ergebnisse</h2></div>
|
|
||||||
<div class="card-body text-muted">Noch kein Health-Check durchgeführt.</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 = `
|
|
||||||
<div class="card" style="margin-bottom:16px;">
|
|
||||||
<div class="card-header"><h2>Verlauf der Health-Check-Runs</h2></div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table style="table-layout:fixed; width:100%;">
|
|
||||||
<colgroup>
|
|
||||||
<col style="width:200px;">
|
|
||||||
<col style="width:160px;">
|
|
||||||
<col style="width:110px;">
|
|
||||||
<col style="width:130px;">
|
|
||||||
<col style="width:110px;">
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Zeitpunkt</th>
|
|
||||||
<th>Run-ID</th>
|
|
||||||
<th style="text-align:center;">Fehler</th>
|
|
||||||
<th style="text-align:center;">Warnungen</th>
|
|
||||||
<th style="text-align:center;">OK</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${healthHistoryCache.map(r => `
|
|
||||||
<tr>
|
|
||||||
<td>${formatDateTime(r.archived_at)}</td>
|
|
||||||
<td class="text-secondary" style="font-size:12px;" title="${esc(r.run_id)}"><code>${esc(String(r.run_id || "").slice(0, 12))}</code></td>
|
|
||||||
<td class="text-danger" style="text-align:center;">${r.errors || 0}</td>
|
|
||||||
<td class="text-warning" style="text-align:center;">${r.warnings || 0}</td>
|
|
||||||
<td class="text-success" style="text-align:center;">${r.ok || 0}</td>
|
|
||||||
</tr>`).join("")}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 loadUnifiedSources === "function") loadUnifiedSources();
|
|
||||||
} catch (err) {
|
|
||||||
showToast("Fehler: " + err.message, "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -56,9 +56,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
setupSourceSubTabs();
|
setupSourceSubTabs();
|
||||||
setupSourceForms();
|
setupSourceForms();
|
||||||
|
|
||||||
// Beim Tab-Wechsel auf "Quellen" laden
|
// Beim Tab-Wechsel auf "Quellen" laden (+ Aufgaben-Badge aktualisieren)
|
||||||
document.querySelectorAll('.nav-tab[data-section="sources"]').forEach((tab) => {
|
document.querySelectorAll('.nav-tab[data-section="sources"]').forEach((tab) => {
|
||||||
tab.addEventListener("click", () => loadUnifiedSources());
|
tab.addEventListener("click", () => {
|
||||||
|
loadUnifiedSources();
|
||||||
|
if (typeof refreshTasksBadge === "function") refreshTasksBadge();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,8 +75,7 @@ function setupSourceSubTabs() {
|
|||||||
document.getElementById("sub-" + subtab).classList.add("active");
|
document.getElementById("sub-" + subtab).classList.add("active");
|
||||||
|
|
||||||
if (subtab === "global-sources") loadUnifiedSources();
|
if (subtab === "global-sources") loadUnifiedSources();
|
||||||
else if (subtab === "source-health") loadHealthData();
|
else if (subtab === "aufgaben") loadAufgaben();
|
||||||
else if (subtab === "classification-review") loadClassificationQueue();
|
|
||||||
else if (subtab === "x-scraper") loadXScraperAccounts();
|
else if (subtab === "x-scraper") loadXScraperAccounts();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -694,181 +696,6 @@ async function addDiscoveredFeeds() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// === Klassifikations-Review ===
|
|
||||||
|
|
||||||
const POLITICAL_LABELS = {
|
|
||||||
links_extrem: { short: "L+", full: "Links (extrem)" },
|
|
||||||
links: { short: "L", full: "Links" },
|
|
||||||
mitte_links: { short: "ML", full: "Mitte-Links" },
|
|
||||||
liberal: { short: "LIB", full: "Liberal" },
|
|
||||||
mitte: { short: "M", full: "Mitte" },
|
|
||||||
konservativ: { short: "KON", full: "Konservativ" },
|
|
||||||
mitte_rechts: { short: "MR", full: "Mitte-Rechts" },
|
|
||||||
rechts: { short: "R", full: "Rechts" },
|
|
||||||
rechts_extrem: { short: "R+", full: "Rechts (extrem)" },
|
|
||||||
na: { short: "?", full: "Nicht eingeordnet" },
|
|
||||||
};
|
|
||||||
const RELIABILITY_LABELS = {
|
|
||||||
sehr_hoch: "Sehr hoch", hoch: "Hoch", gemischt: "Gemischt",
|
|
||||||
niedrig: "Niedrig", sehr_niedrig: "Sehr niedrig", na: "Nicht eingeordnet",
|
|
||||||
};
|
|
||||||
const MEDIA_TYPE_LABELS = {
|
|
||||||
tageszeitung: "Tageszeitung", wochenzeitung: "Wochenzeitung", magazin: "Magazin",
|
|
||||||
tv_sender: "TV-Sender", radio: "Radio", oeffentlich_rechtlich: "Öffentlich-Rechtlich",
|
|
||||||
nachrichtenagentur: "Nachrichtenagentur", online_only: "Online-only", blog: "Blog",
|
|
||||||
telegram_kanal: "Telegram-Kanal", telegram_bot: "Telegram-Bot", podcast: "Podcast",
|
|
||||||
social_media: "Social Media", imageboard: "Imageboard", think_tank: "Think Tank",
|
|
||||||
ngo: "NGO", behoerde: "Behörde", staatsmedium: "Staatsmedium",
|
|
||||||
fachmedium: "Fachmedium", sonstige: "Sonstige",
|
|
||||||
};
|
|
||||||
async function refreshClassificationStats() {
|
|
||||||
try {
|
|
||||||
const stats = await API.get("/api/sources/classification/stats");
|
|
||||||
const badge = document.getElementById("classificationPendingBadge");
|
|
||||||
if (badge) badge.textContent = String(stats.pending_review || 0);
|
|
||||||
} catch (_) { /* still ok */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadClassificationQueue() {
|
|
||||||
const list = document.getElementById("classificationReviewList");
|
|
||||||
if (!list) return;
|
|
||||||
const minConf = parseFloat(document.getElementById("reviewMinConfidence")?.value || "0");
|
|
||||||
list.innerHTML = '<div class="text-muted" style="padding:24px;text-align:center;">Lade…</div>';
|
|
||||||
try {
|
|
||||||
const items = await API.get(`/api/sources/classification/queue?limit=200&min_confidence=${minConf}`);
|
|
||||||
const countEl = document.getElementById("reviewPendingCount");
|
|
||||||
if (countEl) countEl.textContent = String(items.length);
|
|
||||||
refreshClassificationStats();
|
|
||||||
if (items.length === 0) {
|
|
||||||
list.innerHTML = '<div class="text-muted" style="padding:24px;text-align:center;">Keine ausstehenden Vorschläge.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
list.innerHTML = items.map((it) => renderClassificationQueueItem(it)).join("");
|
|
||||||
} catch (err) {
|
|
||||||
list.innerHTML = `<div class="text-danger" style="padding:24px;text-align:center;">Fehler: ${esc(err.message)}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderClassificationQueueItem(item) {
|
|
||||||
const cur = item.current || {};
|
|
||||||
const prop = item.proposed || {};
|
|
||||||
const conf = prop.confidence || 0;
|
|
||||||
const confPct = Math.round(conf * 100);
|
|
||||||
const confClass = conf >= 0.85 ? "high" : conf >= 0.7 ? "medium" : "low";
|
|
||||||
|
|
||||||
const polFmt = (v) => (v && v !== "na" ? POLITICAL_LABELS[v]?.full || v : "–");
|
|
||||||
const mtFmt = (v) => (v ? MEDIA_TYPE_LABELS[v] || v : "–");
|
|
||||||
const relFmt = (v) => (v && v !== "na" ? RELIABILITY_LABELS[v] || v : "–");
|
|
||||||
const stateFmt = (v) => (v ? "ja" : "nein");
|
|
||||||
const ccFmt = (v) => v || "–";
|
|
||||||
|
|
||||||
const row = (label, c, p, fmt) => {
|
|
||||||
const cs = fmt(c);
|
|
||||||
const ps = fmt(p);
|
|
||||||
const changed = cs !== ps;
|
|
||||||
return `<div class="review-diff-row${changed ? " changed" : ""}">
|
|
||||||
<span class="review-diff-label">${esc(label)}</span>
|
|
||||||
<span class="review-diff-current">${esc(cs)}</span>
|
|
||||||
<span class="review-diff-arrow">→</span>
|
|
||||||
<span class="review-diff-proposed">${esc(ps)}</span>
|
|
||||||
</div>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const reasoning = prop.reasoning ? esc(prop.reasoning) : "";
|
|
||||||
|
|
||||||
return `<div class="review-card" data-source-id="${item.id}">
|
|
||||||
<div class="review-card-header">
|
|
||||||
<div class="review-card-title">
|
|
||||||
<span class="review-card-name">${esc(item.name)}</span>
|
|
||||||
${item.is_global ? '<span class="review-global-badge">Grundquelle</span>' : ""}
|
|
||||||
<span class="review-card-domain">${esc(item.domain || "")}</span>
|
|
||||||
</div>
|
|
||||||
<div class="review-card-confidence conf-${confClass}" title="LLM-Konfidenz">
|
|
||||||
<span class="conf-value">${confPct}%</span>
|
|
||||||
<span class="conf-label">Konfidenz</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="review-card-diff">
|
|
||||||
${row("Politik", cur.political_orientation, prop.political_orientation, polFmt)}
|
|
||||||
${row("Medientyp", cur.media_type, prop.media_type, mtFmt)}
|
|
||||||
${row("Glaubwürdigkeit", cur.reliability, prop.reliability, relFmt)}
|
|
||||||
${row("Staatsnah", cur.state_affiliated, prop.state_affiliated, stateFmt)}
|
|
||||||
${row("Land", cur.country_code, prop.country_code, ccFmt)}
|
|
||||||
</div>
|
|
||||||
${reasoning ? `<div class="review-card-reasoning"><strong>Begründung:</strong> ${reasoning}</div>` : ""}
|
|
||||||
<div class="review-card-actions">
|
|
||||||
<button class="btn btn-small btn-primary" onclick="approveClassification(${item.id})">Übernehmen</button>
|
|
||||||
<button class="btn btn-small btn-secondary" onclick="rejectClassification(${item.id})">Verwerfen</button>
|
|
||||||
<button class="btn btn-small btn-secondary" data-reclassify-id="${item.id}" onclick="reclassifySource(${item.id})">Neu klassifizieren</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function approveClassification(id) {
|
|
||||||
try {
|
|
||||||
await API.post(`/api/sources/${id}/classification/approve`, {});
|
|
||||||
showToast("Klassifikation übernommen.", "success");
|
|
||||||
loadClassificationQueue();
|
|
||||||
} catch (err) {
|
|
||||||
showToast("Approve fehlgeschlagen: " + err.message, "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rejectClassification(id) {
|
|
||||||
try {
|
|
||||||
await API.post(`/api/sources/${id}/classification/reject`, {});
|
|
||||||
showToast("Vorschlag verworfen.", "success");
|
|
||||||
loadClassificationQueue();
|
|
||||||
} catch (err) {
|
|
||||||
showToast("Reject fehlgeschlagen: " + err.message, "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reclassifySource(id) {
|
|
||||||
const btn = document.querySelector(`[data-reclassify-id="${id}"]`);
|
|
||||||
if (btn) { btn.disabled = true; btn.textContent = "..."; }
|
|
||||||
try {
|
|
||||||
await API.post(`/api/sources/${id}/classification/reclassify`, {});
|
|
||||||
showToast("Neu klassifiziert.", "success");
|
|
||||||
loadClassificationQueue();
|
|
||||||
} catch (err) {
|
|
||||||
showToast("Reclassify fehlgeschlagen: " + err.message, "error");
|
|
||||||
} finally {
|
|
||||||
if (btn) { btn.disabled = false; btn.textContent = "Neu klassifizieren"; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function triggerBulkClassify() {
|
|
||||||
if (!confirm("Bulk-Klassifikation aller noch nicht klassifizierten Quellen starten? Läuft im Hintergrund (~3-5 Sek pro Quelle, ~0.02 USD pro Quelle).")) return;
|
|
||||||
try {
|
|
||||||
const r = await API.post("/api/sources/classification/bulk-classify?limit=500&only_unclassified=true", {});
|
|
||||||
showToast(`Bulk-Klassifikation gestartet (limit=${r.limit}). In ~10 min neu laden.`, "info");
|
|
||||||
} catch (err) {
|
|
||||||
showToast("Start fehlgeschlagen: " + err.message, "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bulkApproveHighConfidence() {
|
|
||||||
if (!confirm("Alle Vorschläge mit Konfidenz ≥ 0.85 genehmigen?")) return;
|
|
||||||
try {
|
|
||||||
const r = await API.post("/api/sources/classification/bulk-approve?min_confidence=0.85", {});
|
|
||||||
showToast(`${r.approved} Vorschläge übernommen.`, "success");
|
|
||||||
loadClassificationQueue();
|
|
||||||
} catch (err) {
|
|
||||||
showToast("Bulk-Approve fehlgeschlagen: " + err.message, "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function triggerExternalReputationSync() {
|
|
||||||
if (!confirm("IFCN- und EUvsDisinfo-Datenbanken jetzt syncen? Läuft im Hintergrund (~30 Sek).")) return;
|
|
||||||
try {
|
|
||||||
await API.post("/api/sources/external-reputation/sync", {});
|
|
||||||
showToast("Externer Sync gestartet. Quellenliste in ~30 Sek neu laden.", "info");
|
|
||||||
} catch (err) {
|
|
||||||
showToast("Sync fehlgeschlagen: " + err.message, "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSourceInfo(id) {
|
function toggleSourceInfo(id) {
|
||||||
const row = document.getElementById("detail-" + id);
|
const row = document.getElementById("detail-" + id);
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ AUTH_PROTECTED = [
|
|||||||
("DELETE", "/api/users/1"),
|
("DELETE", "/api/users/1"),
|
||||||
("GET", "/api/dashboard/stats"),
|
("GET", "/api/dashboard/stats"),
|
||||||
("GET", "/api/sources/meta"),
|
("GET", "/api/sources/meta"),
|
||||||
|
("GET", "/api/sources/tasks/summary"),
|
||||||
("GET", "/api/sources"),
|
("GET", "/api/sources"),
|
||||||
("POST", "/api/sources/global"),
|
("POST", "/api/sources/global"),
|
||||||
("PUT", "/api/sources/global/1"),
|
("PUT", "/api/sources/global/1"),
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren