Promote develop → main (2026-07-25 22:22 UTC) #16
@@ -70,6 +70,10 @@ async def list_audit(
|
||||
where.append("ts >= ?")
|
||||
params.append(from_ts)
|
||||
if to_ts:
|
||||
# Datums-Only-Wert (aus <input type=date>) soll den gewählten Tag
|
||||
# komplett EINschließen, sonst fiele er wegen ts <= 'YYYY-MM-DD' raus.
|
||||
if len(to_ts) == 10:
|
||||
to_ts = to_ts + " 23:59:59"
|
||||
where.append("ts <= ?")
|
||||
params.append(to_ts)
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ async def tasks_summary(
|
||||
):
|
||||
"""Billiger Zähler für den Aufgaben-Badge im Quellen-Reiter.
|
||||
|
||||
classification_pending nutzt DIESELBE Bedingung wie /classification/stats,
|
||||
classification_pending nutzt DIESELBE Bedingung wie /classification/queue,
|
||||
damit Badge und Review-Karten nie auseinanderlaufen.
|
||||
"""
|
||||
suggestions_pending = 0
|
||||
@@ -403,13 +403,13 @@ async def get_sources_stats(
|
||||
):
|
||||
"""Aggregierte Stats für die Stats-Bar der vereinten Quellenliste.
|
||||
|
||||
Zählt Grund- UND Kundenquellen. by_origin liefert die Aufteilung,
|
||||
last_check den Zeitpunkt des jüngsten Health-Checks.
|
||||
Zählt Grund- UND Kundenquellen, aktive wie inaktive (die Liste zeigt
|
||||
ebenfalls alle). by_origin liefert die Aufteilung, last_check den
|
||||
Zeitpunkt des jüngsten Health-Checks.
|
||||
"""
|
||||
cur = await db.execute("""
|
||||
SELECT source_type, COUNT(*) AS count, COALESCE(SUM(article_count), 0) AS articles
|
||||
FROM sources
|
||||
WHERE status = 'active'
|
||||
GROUP BY source_type
|
||||
""")
|
||||
by_type = {}
|
||||
@@ -425,7 +425,6 @@ async def get_sources_stats(
|
||||
SELECT CASE WHEN tenant_id IS NULL THEN 'global' ELSE 'tenant' END AS origin,
|
||||
COUNT(*) AS cnt
|
||||
FROM sources
|
||||
WHERE status = 'active'
|
||||
GROUP BY origin
|
||||
""")
|
||||
by_origin = {dict(r)["origin"]: dict(r)["cnt"] for r in await cur.fetchall()}
|
||||
@@ -441,7 +440,6 @@ async def get_sources_stats(
|
||||
SELECT h.status AS hs, COUNT(DISTINCT h.source_id) AS cnt
|
||||
FROM source_health_checks h
|
||||
JOIN sources s ON s.id = h.source_id
|
||||
WHERE s.status = 'active'
|
||||
GROUP BY h.status
|
||||
""")
|
||||
for r in await cur.fetchall():
|
||||
@@ -716,107 +714,6 @@ async def add_discovered_sources(
|
||||
|
||||
# --- Health-Check & Vorschläge ---
|
||||
|
||||
@router.get("/health")
|
||||
async def get_health(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
admin: dict = Depends(get_current_admin),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Health-Check-Ergebnisse abrufen.
|
||||
|
||||
Default-Limit 100, sortiert nach Status (errors first, dann warnings, dann ok).
|
||||
Counters (errors/warnings/ok/total_checks) beziehen sich auf den GESAMTEN
|
||||
Datenbestand, nicht nur auf die zurückgegebene Page. Damit kann das Frontend
|
||||
den vollen Status anzeigen, ohne alle Zeilen rendern zu müssen.
|
||||
has_more zeigt an, ob es weitere Items zum Nachladen gibt.
|
||||
all_orgs liefert die Liste aller Tenants mit Health-Checks (für Filter-Dropdown).
|
||||
"""
|
||||
limit = max(1, min(int(limit or 100), 5000))
|
||||
offset = max(0, int(offset or 0))
|
||||
|
||||
# Prüfen ob Tabelle existiert
|
||||
cursor = await db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks'"
|
||||
)
|
||||
if not await cursor.fetchone():
|
||||
return {
|
||||
"last_check": None, "total_checks": 0,
|
||||
"errors": 0, "warnings": 0, "ok": 0,
|
||||
"checks": [], "all_orgs": [],
|
||||
"limit": limit, "offset": offset, "has_more": False,
|
||||
}
|
||||
|
||||
# Aggregate über GESAMTEN Bestand. Eine GROUP-BY-Query nach (check_type, status)
|
||||
# liefert sowohl die Top-Counters als auch das feine Breakdown für die UI.
|
||||
cursor = await db.execute(
|
||||
"SELECT check_type, status, COUNT(*) AS n FROM source_health_checks GROUP BY check_type, status"
|
||||
)
|
||||
breakdown = {} # {check_type: {status: count}}
|
||||
error_count = 0
|
||||
warning_count = 0
|
||||
ok_count = 0
|
||||
for row in await cursor.fetchall():
|
||||
ct = row["check_type"]
|
||||
st = row["status"]
|
||||
breakdown.setdefault(ct, {})[st] = row["n"]
|
||||
if st == "error":
|
||||
error_count += row["n"]
|
||||
elif st == "warning":
|
||||
warning_count += row["n"]
|
||||
elif st == "ok":
|
||||
ok_count += row["n"]
|
||||
total_checks = error_count + warning_count + ok_count
|
||||
|
||||
# Paginierte Daten
|
||||
cursor = await db.execute("""
|
||||
SELECT
|
||||
h.source_id, s.name, s.domain, s.tenant_id, s.language,
|
||||
o.name AS org_name,
|
||||
h.check_type, h.status, h.message
|
||||
FROM source_health_checks h
|
||||
JOIN sources s ON s.id = h.source_id
|
||||
LEFT JOIN organizations o ON o.id = s.tenant_id
|
||||
ORDER BY
|
||||
CASE h.status WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
|
||||
s.name
|
||||
LIMIT ? OFFSET ?
|
||||
""", (limit, offset))
|
||||
checks = [dict(row) for row in await cursor.fetchall()]
|
||||
|
||||
# Org-Liste (alle Tenants mit Health-Checks, für Frontend-Filter-Dropdown)
|
||||
cursor = await db.execute("""
|
||||
SELECT DISTINCT s.tenant_id AS id, o.name AS name
|
||||
FROM source_health_checks h
|
||||
JOIN sources s ON s.id = h.source_id
|
||||
LEFT JOIN organizations o ON o.id = s.tenant_id
|
||||
WHERE s.tenant_id IS NOT NULL
|
||||
ORDER BY o.name
|
||||
""")
|
||||
all_orgs = [dict(row) for row in await cursor.fetchall()]
|
||||
|
||||
cursor = await db.execute("SELECT MAX(checked_at) as last_check FROM source_health_checks")
|
||||
row = await cursor.fetchone()
|
||||
last_check = row["last_check"] if row else None
|
||||
|
||||
return {
|
||||
"last_check": last_check,
|
||||
"total_checks": total_checks,
|
||||
"errors": error_count,
|
||||
"warnings": warning_count,
|
||||
"ok": ok_count,
|
||||
"breakdown": breakdown,
|
||||
"checks": checks,
|
||||
"all_orgs": all_orgs,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": (offset + len(checks)) < total_checks,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/health/history")
|
||||
async def get_health_history(
|
||||
limit: int = 20,
|
||||
@@ -929,7 +826,11 @@ async def get_suggestions(
|
||||
admin: dict = Depends(get_current_admin),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Alle Vorschläge abrufen (pending zuerst, dann letzte 20 bearbeitete)."""
|
||||
"""Alle OFFENEN Vorschläge plus die letzten 50 bearbeiteten.
|
||||
|
||||
Kein Deckel auf pending, sonst liefe der Aufgaben-Badge
|
||||
(tasks/summary zählt ungedeckelt) der Tabelle davon.
|
||||
"""
|
||||
cursor = await db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='source_suggestions'"
|
||||
)
|
||||
@@ -938,12 +839,17 @@ async def get_suggestions(
|
||||
|
||||
cursor = await db.execute("""
|
||||
SELECT * FROM source_suggestions
|
||||
ORDER BY
|
||||
CASE status WHEN 'pending' THEN 0 ELSE 1 END,
|
||||
created_at DESC
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at DESC
|
||||
""")
|
||||
pending = [dict(row) for row in await cursor.fetchall()]
|
||||
cursor = await db.execute("""
|
||||
SELECT * FROM source_suggestions
|
||||
WHERE status != 'pending'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50
|
||||
""")
|
||||
return [dict(row) for row in await cursor.fetchall()]
|
||||
return pending + [dict(row) for row in await cursor.fetchall()]
|
||||
|
||||
|
||||
class SuggestionAction(BaseModel):
|
||||
@@ -1172,31 +1078,6 @@ Nur das JSON, kein anderer Text."""
|
||||
# === Klassifikations-Review (LLM-Vorschlaege approve/reject/reclassify) ===
|
||||
|
||||
|
||||
@router.get("/classification/stats")
|
||||
async def classification_stats(
|
||||
admin: dict = Depends(get_current_admin),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Counts pro classification_source-Wert + Anzahl Pending-Reviews (alle Quellen)."""
|
||||
cursor = await db.execute(
|
||||
"""SELECT classification_source, COUNT(*) as cnt
|
||||
FROM sources
|
||||
WHERE status = 'active'
|
||||
GROUP BY classification_source"""
|
||||
)
|
||||
by_source = {row["classification_source"] or "legacy": row["cnt"] for row in await cursor.fetchall()}
|
||||
cursor = await db.execute(
|
||||
"""SELECT COUNT(*) as cnt FROM sources
|
||||
WHERE status = 'active' AND proposed_political_orientation IS NOT NULL"""
|
||||
)
|
||||
pending = (await cursor.fetchone())["cnt"]
|
||||
return {
|
||||
"by_classification_source": by_source,
|
||||
"pending_review": pending,
|
||||
"total": sum(by_source.values()),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/classification/queue")
|
||||
async def classification_queue(
|
||||
limit: int = 50,
|
||||
|
||||
@@ -586,36 +586,6 @@ tr:hover td {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* --- Recent activity --- */
|
||||
.activity-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.activity-icon.org {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.activity-icon.user {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
/* --- Org detail panel --- */
|
||||
.detail-panel {
|
||||
display: none;
|
||||
@@ -702,21 +672,6 @@ tr:hover td {
|
||||
|
||||
|
||||
/* --- Health & Suggestion Badges --- */
|
||||
.badge-health-error {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.badge-health-warning {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.badge-health-ok {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.badge-suggestion-add_source {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #93c5fd;
|
||||
@@ -1133,13 +1088,6 @@ table.show-select .select-col { display: table-cell; }
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.review-conf-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.review-toolbar-actions { display: flex; gap: 6px; }
|
||||
|
||||
.review-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
@@ -1440,12 +1388,6 @@ details.adv-section .adv-hint { grid-column: 1 / -1; font-size: 12px; color: var
|
||||
[data-theme="light"] .badge-expired { color: #B91C1C; }
|
||||
[data-theme="light"] .badge-revoked { color: #475569; }
|
||||
[data-theme="light"] .badge-none { color: #475569; }
|
||||
[data-theme="light"] .activity-icon.org { color: #1D4ED8; }
|
||||
[data-theme="light"] .activity-icon.user { color: #047857; }
|
||||
[data-theme="light"] /* --- Health & Suggestion Badges --- */
|
||||
.badge-health-error { color: #B91C1C; }
|
||||
[data-theme="light"] .badge-health-warning { color: #B45309; }
|
||||
[data-theme="light"] .badge-health-ok { color: #047857; }
|
||||
[data-theme="light"] .badge-suggestion-add_source { color: #1D4ED8; }
|
||||
[data-theme="light"] .badge-suggestion-deactivate_source { color: #B45309; }
|
||||
[data-theme="light"] .badge-suggestion-remove_source { color: #B91C1C; }
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>AegisSight Monitor-Verwaltung</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="apple-touch-icon" href="/static/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20260725l">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20260725m">
|
||||
<script>(function(){var t=localStorage.getItem('portal_theme');if(t==='light')document.documentElement.setAttribute('data-theme','light');try{var a=JSON.parse(localStorage.getItem('osint_a11y')||'{}');Object.keys(a).forEach(function(k){if(a[k])document.documentElement.setAttribute('data-a11y-'+k,'true');});}catch(e){}})()</script>
|
||||
|
||||
<style>
|
||||
@@ -341,7 +341,7 @@
|
||||
<div class="review-toolbar-actions">
|
||||
<button class="btn btn-secondary btn-small" onclick="triggerExternalReputationSync()" title="IFCN-Faktenchecker und EUvsDisinfo neu syncen">Externe Daten syncen</button>
|
||||
<button class="btn btn-secondary btn-small" onclick="triggerBulkClassify()" title="LLM-Klassifikation für noch unklassifizierte Quellen starten">+ Klassifikation starten</button>
|
||||
<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" id="bulkApproveBtn" onclick="bulkApproveHighConfidence()" disabled title="Erst aktiv, wenn Klassifikations-Vorschläge ausstehen">Alle ≥ 0.85 genehmigen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-bar" id="aufgabenFilterBar"></div>
|
||||
@@ -896,7 +896,7 @@
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="confirmCancelBtn" onclick="closeModal('modalConfirm')">Abbrechen</button>
|
||||
<button class="btn btn-danger" id="confirmOkBtn">Bestätigen</button>
|
||||
<button class="btn btn-primary" id="confirmOkBtn">Bestätigen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -970,10 +970,10 @@
|
||||
</div>
|
||||
|
||||
<script src="/static/js/a11y.js?v=20260725a"></script>
|
||||
<script src="/static/js/app.js?v=20260725u"></script>
|
||||
<script src="/static/js/sources.js?v=20260725e"></script>
|
||||
<script src="/static/js/aufgaben.js?v=20260725b"></script>
|
||||
<script src="/static/js/x-scraper.js?v=20260522a"></script>
|
||||
<script src="/static/js/app.js?v=20260725v"></script>
|
||||
<script src="/static/js/sources.js?v=20260725f"></script>
|
||||
<script src="/static/js/aufgaben.js?v=20260725c"></script>
|
||||
<script src="/static/js/x-scraper.js?v=20260725a"></script>
|
||||
<script src="/static/js/audit.js?v=20260725a"></script>
|
||||
<div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div>
|
||||
</body>
|
||||
|
||||
@@ -437,7 +437,8 @@ function confirmDeleteOrg() {
|
||||
} catch (err) {
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
}
|
||||
},
|
||||
{ danger: true }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -533,7 +534,8 @@ function confirmDeleteUser(userId, email) {
|
||||
} catch (err) {
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
}
|
||||
},
|
||||
{ danger: true }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -834,9 +836,19 @@ let confirmCallback = null;
|
||||
|
||||
let confirmResolver = null;
|
||||
|
||||
function showConfirm(title, text, callback) {
|
||||
function showConfirm(title, text, callback, opts) {
|
||||
// Promise-Stil erlaubt opts als dritten Parameter: showConfirm(t, x, {danger:true})
|
||||
if (callback && typeof callback === "object") { opts = callback; callback = null; }
|
||||
opts = opts || {};
|
||||
document.getElementById("confirmTitle").textContent = title;
|
||||
document.getElementById("confirmText").textContent = text;
|
||||
// Bestätigen-Knopf ist standardmäßig Gold. Rot nur für destruktive
|
||||
// Aktionen (Löschen, Entfernen), per opts.danger = true.
|
||||
const okBtn = document.getElementById("confirmOkBtn");
|
||||
if (okBtn) {
|
||||
okBtn.classList.toggle("btn-danger", !!opts.danger);
|
||||
okBtn.classList.toggle("btn-primary", !opts.danger);
|
||||
}
|
||||
// Backward-compat: legacy Callback wird bei OK aufgerufen
|
||||
confirmCallback = callback || null;
|
||||
openModal("modalConfirm");
|
||||
@@ -889,7 +901,10 @@ function esc(str) {
|
||||
if (!str) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
// Auch Anführungszeichen maskieren. Viele Templates setzen esc() in
|
||||
// HTML-Attribute (onclick='...', title="...") ein, dort bricht ein
|
||||
// Apostroph im Quellennamen (z.B. L'Express) sonst das Markup.
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
|
||||
@@ -46,6 +46,11 @@ async function refreshTasksBadge() {
|
||||
} catch (_) { /* Badge ist nicht kritisch */ }
|
||||
}
|
||||
|
||||
// Badge schon beim Seitenstart füllen, nicht erst beim Öffnen des Quellen-Reiters
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
if (window.API && localStorage.getItem("token")) refreshTasksBadge();
|
||||
});
|
||||
|
||||
// --- Aufgaben laden ---
|
||||
async function loadAufgaben() {
|
||||
renderAufgabenFilters();
|
||||
@@ -137,7 +142,7 @@ function renderAufgabenSuggestions() {
|
||||
<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 class="text-secondary" style="max-width:420px; white-space:normal; overflow-wrap:anywhere;">${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;">
|
||||
@@ -282,7 +287,7 @@ async function searchFix(btn) {
|
||||
showToast("Fehler. " + err.message, "error");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Lösung suchen";
|
||||
btn.innerHTML = LUCIDE_ICONS.search; // Icon wiederherstellen, kein Text
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +345,14 @@ function renderClassificationList() {
|
||||
: all;
|
||||
const countEl = document.getElementById("reviewPendingCount");
|
||||
if (countEl) countEl.textContent = String(all.length);
|
||||
// Sammel-Genehmigung nur anbieten, wenn überhaupt etwas aussteht
|
||||
const bulkBtn = document.getElementById("bulkApproveBtn");
|
||||
if (bulkBtn) {
|
||||
bulkBtn.disabled = all.length === 0;
|
||||
bulkBtn.title = all.length === 0
|
||||
? "Erst aktiv, wenn Klassifikations-Vorschläge ausstehen"
|
||||
: "Alle Vorschläge ab 0.85 Konfidenz übernehmen";
|
||||
}
|
||||
if (all.length === 0) {
|
||||
list.innerHTML = '<div class="text-muted" style="padding:24px;text-align:center;">Keine ausstehenden Klassifikationen.</div>';
|
||||
return;
|
||||
@@ -444,7 +457,9 @@ async function reclassifySource(id) {
|
||||
}
|
||||
|
||||
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;
|
||||
const ok = await showConfirm("Klassifikation starten",
|
||||
"Bulk-Klassifikation aller noch nicht klassifizierten Quellen starten? Läuft im Hintergrund (~3-5 Sek pro Quelle, ~0.02 USD pro Quelle).");
|
||||
if (!ok) 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");
|
||||
@@ -454,7 +469,9 @@ async function triggerBulkClassify() {
|
||||
}
|
||||
|
||||
async function bulkApproveHighConfidence() {
|
||||
if (!confirm("Alle Vorschläge mit Konfidenz ≥ 0.85 genehmigen?")) return;
|
||||
const ok = await showConfirm("Sammel-Genehmigung",
|
||||
"Alle Vorschläge mit Konfidenz ≥ 0.85 genehmigen?");
|
||||
if (!ok) return;
|
||||
try {
|
||||
const r = await API.post("/api/sources/classification/bulk-approve?min_confidence=0.85", {});
|
||||
showToast(`${r.approved} Vorschläge übernommen.`, "success");
|
||||
@@ -466,7 +483,9 @@ async function bulkApproveHighConfidence() {
|
||||
}
|
||||
|
||||
async function triggerExternalReputationSync() {
|
||||
if (!confirm("IFCN- und EUvsDisinfo-Datenbanken jetzt syncen? Läuft im Hintergrund (~30 Sek).")) return;
|
||||
const ok = await showConfirm("Externe Daten syncen",
|
||||
"IFCN- und EUvsDisinfo-Datenbanken jetzt syncen? Läuft im Hintergrund (~30 Sek).");
|
||||
if (!ok) return;
|
||||
try {
|
||||
await API.post("/api/sources/external-reputation/sync", {});
|
||||
showToast("Externer Sync gestartet. Quellenliste in ~30 Sek neu laden.", "info");
|
||||
|
||||
@@ -175,7 +175,11 @@ function renderAuditEntries(items) {
|
||||
function formatDateTime(iso) {
|
||||
if (!iso) return "-";
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
let v = String(iso);
|
||||
// SQLite liefert CURRENT_TIMESTAMP als UTC ohne Zeitzonen-Kennung.
|
||||
// Ohne das Z würde der Browser den Wert als Lokalzeit fehlinterpretieren.
|
||||
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(v)) v = v.replace(" ", "T") + "Z";
|
||||
const d = new Date(v);
|
||||
return d.toLocaleString("de-DE", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
@@ -192,10 +196,8 @@ function renderUnifiedStats(stats) {
|
||||
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>`);
|
||||
}
|
||||
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${bo.tenant || 0}</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>`);
|
||||
@@ -398,6 +400,14 @@ function filterUnifiedSources() {
|
||||
vb = parseInt(vb) || 0;
|
||||
return globalSortAsc ? va - vb : vb - va;
|
||||
}
|
||||
if (globalSortField === "health_status") {
|
||||
// Nach Schweregrad sortieren (error > warning > ok > unbekannt),
|
||||
// nicht alphabetisch
|
||||
const RANK = { error: 3, warning: 2, ok: 1 };
|
||||
va = RANK[va] || 0;
|
||||
vb = RANK[vb] || 0;
|
||||
return globalSortAsc ? va - vb : vb - va;
|
||||
}
|
||||
va = String(va).toLowerCase();
|
||||
vb = String(vb).toLowerCase();
|
||||
const cmp = va.localeCompare(vb, "de");
|
||||
@@ -550,7 +560,8 @@ function confirmDeleteGlobalSource(id, name) {
|
||||
} catch (err) {
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
}
|
||||
},
|
||||
{ danger: true }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,8 @@ function confirmDeleteXScraper(username) {
|
||||
} catch (err) {
|
||||
showToast(err.message || "Konto konnte nicht entfernt werden", "error");
|
||||
}
|
||||
}
|
||||
},
|
||||
{ danger: true }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,12 +52,20 @@ AUTH_PROTECTED = [
|
||||
("POST", "/api/sources/tenant/bulk-promote"),
|
||||
("POST", "/api/sources/discover"),
|
||||
("POST", "/api/sources/discover/add"),
|
||||
("GET", "/api/sources/health"),
|
||||
("GET", "/api/sources/health/history"),
|
||||
("GET", "/api/sources/suggestions"),
|
||||
("PUT", "/api/sources/suggestions/1"),
|
||||
("POST", "/api/sources/health/run"),
|
||||
("GET", "/api/sources/health/run-status"),
|
||||
("POST", "/api/sources/health/search-fix/1"),
|
||||
("GET", "/api/sources/classification/queue"),
|
||||
("POST", "/api/sources/classification/bulk-classify"),
|
||||
("POST", "/api/sources/classification/bulk-approve"),
|
||||
("POST", "/api/sources/1/classification/approve"),
|
||||
("POST", "/api/sources/1/classification/reject"),
|
||||
("POST", "/api/sources/1/classification/reclassify"),
|
||||
("POST", "/api/sources/external-reputation/sync"),
|
||||
("POST", "/api/sources/global/upload-pdf"),
|
||||
("GET", "/api/statistik"),
|
||||
("GET", "/api/token-usage/overview"),
|
||||
("GET", "/api/token-usage/1"),
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren