fix(sources): Feinschliff nach Konsistenz-Pruefung + UI-Rueckmeldungen

Echte Fehler:
- esc() maskiert jetzt auch Anfuehrungszeichen. Quellennamen mit
  Apostroph (z.B. LExpress) brachen sonst die Zeilen-Knoepfe.
- formatDateTime parst SQLite-UTC-Zeitstempel jetzt als UTC statt
  Lokalzeit (Zeiten waren um den Zeitzonen-Offset verschoben).
- Audit-Filter Bis-Datum schliesst den gewaehlten Tag jetzt komplett ein.

UI-Rueckmeldungen:
- Beschreibung in der Vorschlags-Tabelle bricht mehrzeilig um statt
  abgeschnitten zu werden.
- Bestaetigen-Knopf im Dialog ist standardmaessig Gold, Rot nur noch bei
  destruktiven Aktionen (Org/Nutzer/Quelle loeschen, X-Konto entfernen).
- Alle >= 0.85 genehmigen ist ausgegraut, solange keine Klassifikationen
  ausstehen.

Konsistenz:
- GET /suggestions deckelt pending nicht mehr auf 50 (Badge und Tabelle
  liefen auseinander), bearbeitete weiterhin letzte 50.
- /stats zaehlt jetzt alle Quellen wie die Liste (nicht nur aktive),
  Grundquellen/Kundenquellen-Aufteilung wird immer angezeigt.
- Health-Spalte sortiert nach Schweregrad statt alphabetisch.
- Aufgaben-Badge laedt schon beim Seitenstart, nicht erst beim Reiterklick.
- Loesung-suchen-Knopf stellt nach Fehler das Icon wieder her, native
  confirm()-Dialoge durch showConfirm ersetzt.
- Tote Endpoints GET /health (Alt-Liste) und GET /classification/stats
  entfernt, tote CSS-Bloecke (activity-*, badge-health-*,
  review-conf-filter) geloescht.
- Smoke-Test deckt jetzt alle Klassifikations-/Upload-/Sync-Endpoints ab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-07-25 18:48:16 +00:00
Ursprung bc3b0bb9ee
Commit c5f63b1d14
9 geänderte Dateien mit 100 neuen und 219 gelöschten Zeilen

Datei anzeigen

@@ -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; }

Datei anzeigen

@@ -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>

Datei anzeigen

@@ -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, "&quot;").replace(/'/g, "&#39;");
}
function formatDate(iso) {

Datei anzeigen

@@ -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");

Datei anzeigen

@@ -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.global || 0}</span> Grundquellen</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 }
);
}

Datei anzeigen

@@ -89,7 +89,8 @@ function confirmDeleteXScraper(username) {
} catch (err) {
showToast(err.message || "Konto konnte nicht entfernt werden", "error");
}
}
},
{ danger: true }
);
}