Promote develop → main (2026-07-25 22:22 UTC) #16

Zusammengeführt
IntelSight_Admin hat 41 Commits von develop nach main 2026-07-26 00:22:10 +02:00 zusammengeführt
7 geänderte Dateien mit 263 neuen und 138 gelöschten Zeilen
Nur Änderungen aus Commit 2df2fa1cfa werden angezeigt - Alle Commits anzeigen

Datei anzeigen

@@ -2,7 +2,7 @@
import json import json
import logging import logging
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException
from auth import get_current_admin from auth import get_current_admin
from database import db_dependency from database import db_dependency
import aiosqlite import aiosqlite
@@ -25,7 +25,7 @@ async def list_audit(
action: Optional[str] = None, action: Optional[str] = None,
resource_type: Optional[str] = None, resource_type: Optional[str] = None,
resource_id: Optional[int] = None, resource_id: Optional[int] = None,
admin_id: Optional[int] = None, admin_id: Optional[str] = None,
from_ts: Optional[str] = None, from_ts: Optional[str] = None,
to_ts: Optional[str] = None, to_ts: Optional[str] = None,
limit: int = 200, limit: int = 200,
@@ -33,7 +33,11 @@ async def list_audit(
admin: dict = Depends(get_current_admin), admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency), db: aiosqlite.Connection = Depends(db_dependency),
): ):
"""Audit-Log-Eintraege auflisten mit Filter + Pagination.""" """Audit-Log-Eintraege auflisten mit Filter + Pagination.
action, resource_type und admin_id akzeptieren kommagetrennte Mehrfachwerte
(Checkbox-Filter im Frontend), ein Einzelwert funktioniert weiterhin.
"""
if limit < 1 or limit > 1000: if limit < 1 or limit > 1000:
limit = 200 limit = 200
if offset < 0: if offset < 0:
@@ -42,17 +46,26 @@ async def list_audit(
where = [] where = []
params = [] params = []
if action: if action:
where.append("action = ?") vals = [v.strip() for v in action.split(",") if v.strip()]
params.append(action) if vals:
where.append(f"action IN ({','.join('?' for _ in vals)})")
params.extend(vals)
if resource_type: if resource_type:
where.append("resource_type = ?") vals = [v.strip() for v in resource_type.split(",") if v.strip()]
params.append(resource_type) if vals:
where.append(f"resource_type IN ({','.join('?' for _ in vals)})")
params.extend(vals)
if resource_id is not None: if resource_id is not None:
where.append("resource_id = ?") where.append("resource_id = ?")
params.append(resource_id) params.append(resource_id)
if admin_id is not None: if admin_id:
where.append("admin_id = ?") try:
params.append(admin_id) ids = [int(v) for v in admin_id.split(",") if v.strip()]
except ValueError:
raise HTTPException(status_code=422, detail="admin_id muss eine Zahl oder kommagetrennte Zahlen sein")
if ids:
where.append(f"admin_id IN ({','.join('?' for _ in ids)})")
params.extend(ids)
if from_ts: if from_ts:
where.append("ts >= ?") where.append("ts >= ?")
params.append(from_ts) params.append(from_ts)

Datei anzeigen

@@ -928,6 +928,49 @@ input[type="date"].filter-select { padding: 6px 10px; }
.health-run-item { display: inline-flex; align-items: center; gap: 8px; } .health-run-item { display: inline-flex; align-items: center; gap: 8px; }
.health-run-item #healthRunInfo { font-size: 12px; } .health-run-item #healthRunInfo { font-size: 12px; }
/* Checkbox-Filterleisten (Standard fuer alle Filter im Portal) */
.filter-bar {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 14px;
margin-bottom: 14px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
}
.filter-group {
display: flex;
align-items: baseline;
gap: 4px 10px;
flex-wrap: wrap;
}
.filter-group-label {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.4px;
min-width: 92px;
cursor: default;
}
.filter-check {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 13px;
cursor: pointer;
padding: 2px 7px;
border-radius: 6px;
user-select: none;
}
.filter-check:hover { background: var(--bg-tertiary); }
.filter-check input {
accent-color: var(--accent);
cursor: pointer;
margin: 0;
}
/* Checkbox-Spalte: nur sichtbar bei Herkunfts-Filter "Kundenquellen" */ /* Checkbox-Spalte: nur sichtbar bei Herkunfts-Filter "Kundenquellen" */
.select-col { display: none; } .select-col { display: none; }
table.show-select .select-col { display: table-cell; } table.show-select .select-col { display: table-cell; }

Datei anzeigen

@@ -6,7 +6,7 @@
<title>AegisSight Monitor-Verwaltung</title> <title>AegisSight Monitor-Verwaltung</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="apple-touch-icon" href="/static/favicon.svg"> <link rel="apple-touch-icon" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/style.css?v=20260725j"> <link rel="stylesheet" href="/static/css/style.css?v=20260725k">
<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> <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> <style>
@@ -296,25 +296,6 @@
<div class="action-bar"> <div class="action-bar">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;"> <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<input type="text" class="search-input" id="globalSourceSearch" placeholder="Quelle suchen..."> <input type="text" class="search-input" id="globalSourceSearch" placeholder="Quelle suchen...">
<select class="filter-select" id="filterOrigin" onchange="filterUnifiedSources()">
<option value="">Alle Quellen</option>
<option value="global">Nur Grundquellen</option>
<option value="tenant">Nur Kundenquellen</option>
</select>
<select class="filter-select" id="globalFilterType" onchange="filterUnifiedSources()">
<option value="">Alle Typen</option>
</select>
<select class="filter-select" id="globalFilterCategory" onchange="filterUnifiedSources()">
<option value="">Alle Kategorien</option>
</select>
<select class="filter-select" id="globalFilterStatus" onchange="filterUnifiedSources()">
<option value="">Alle Status</option>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
<select class="filter-select" id="globalFilterLanguage" onchange="filterUnifiedSources()">
<option value="">Alle Sprachen</option>
</select>
<span class="text-secondary" id="globalSourceCount"></span> <span class="text-secondary" id="globalSourceCount"></span>
</div> </div>
<button class="btn btn-primary" id="tenantBulkPromoteBtn" onclick="bulkPromoteSelected()" disabled style="display:none;margin-right:8px;">Ausgewählte übernehmen (0)</button> <button class="btn btn-primary" id="tenantBulkPromoteBtn" onclick="bulkPromoteSelected()" disabled style="display:none;margin-right:8px;">Ausgewählte übernehmen (0)</button>
@@ -322,6 +303,7 @@
<button class="btn btn-secondary" id="newPdfSourceBtn" style="margin-right:8px;">+ PDF hochladen</button> <button class="btn btn-secondary" id="newPdfSourceBtn" style="margin-right:8px;">+ PDF hochladen</button>
<button class="btn btn-primary" id="newGlobalSourceBtn">+ Neue Grundquelle</button> <button class="btn btn-primary" id="newGlobalSourceBtn">+ Neue Grundquelle</button>
</div> </div>
<div class="filter-bar" id="unifiedFilterBar"></div>
<div class="card"> <div class="card">
<div class="table-wrap"> <div class="table-wrap">
<table id="unifiedTable"> <table id="unifiedTable">
@@ -355,16 +337,6 @@
<div class="review-toolbar-info"> <div class="review-toolbar-info">
<span><strong id="aufgabenSuggestionCount">0</strong> Vorschläge offen</span> <span><strong id="aufgabenSuggestionCount">0</strong> Vorschläge offen</span>
<span><strong id="reviewPendingCount">0</strong> Klassifikationen ausstehend</span> <span><strong id="reviewPendingCount">0</strong> Klassifikationen ausstehend</span>
<label class="review-conf-filter">
Mindest-Konfidenz
<select class="filter-select" id="reviewMinConfidence" onchange="loadClassificationQueue()">
<option value="0">alle</option>
<option value="0.5">0.5+</option>
<option value="0.7">0.7+</option>
<option value="0.85">0.85+</option>
<option value="0.9">0.9+</option>
</select>
</label>
</div> </div>
<div class="review-toolbar-actions"> <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="triggerExternalReputationSync()" title="IFCN-Faktenchecker und EUvsDisinfo neu syncen">Externe Daten syncen</button>
@@ -372,6 +344,7 @@
<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="filter-bar" id="aufgabenFilterBar"></div>
<div id="aufgabenSuggestions"></div> <div id="aufgabenSuggestions"></div>
<div class="card" style="margin-bottom:16px;"> <div class="card" style="margin-bottom:16px;">
<div class="card-header"><h2>Klassifikations-Review</h2></div> <div class="card-header"><h2>Klassifikations-Review</h2></div>
@@ -419,16 +392,7 @@
<div class="section" id="sec-audit"> <div class="section" id="sec-audit">
<div class="action-bar" style="flex-wrap:wrap;gap:8px;"> <div class="action-bar" style="flex-wrap:wrap;gap:8px;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;"> <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<select class="filter-select" id="auditFilterAction">
<option value="">Alle Aktionen</option>
</select>
<select class="filter-select" id="auditFilterResource">
<option value="">Alle Ressourcen</option>
</select>
<input type="number" class="filter-select" id="auditFilterResourceId" placeholder="Ressourcen-ID" min="1" style="width:130px;"> <input type="number" class="filter-select" id="auditFilterResourceId" placeholder="Ressourcen-ID" min="1" style="width:130px;">
<select class="filter-select" id="auditFilterAdmin">
<option value="">Alle Admins</option>
</select>
<input type="date" class="filter-select" id="auditFilterFrom" title="Von (Datum)"> <input type="date" class="filter-select" id="auditFilterFrom" title="Von (Datum)">
<input type="date" class="filter-select" id="auditFilterTo" title="Bis (Datum)"> <input type="date" class="filter-select" id="auditFilterTo" title="Bis (Datum)">
<button class="btn btn-secondary btn-small" id="auditFilterReset">Filter zuruecksetzen</button> <button class="btn btn-secondary btn-small" id="auditFilterReset">Filter zuruecksetzen</button>
@@ -439,6 +403,7 @@
<button class="btn btn-secondary btn-small" id="auditNextBtn" disabled>Weiter &rarr;</button> <button class="btn btn-secondary btn-small" id="auditNextBtn" disabled>Weiter &rarr;</button>
</div> </div>
</div> </div>
<div class="filter-bar" id="auditFilterBar"></div>
<div class="card"> <div class="card">
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
@@ -1005,11 +970,11 @@
</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=20260725s"></script> <script src="/static/js/app.js?v=20260725t"></script>
<script src="/static/js/sources.js?v=20260725d"></script> <script src="/static/js/sources.js?v=20260725e"></script>
<script src="/static/js/aufgaben.js?v=20260725a"></script> <script src="/static/js/aufgaben.js?v=20260725b"></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/audit.js?v=20260509d"></script> <script src="/static/js/audit.js?v=20260725a"></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>
</html> </html>

Datei anzeigen

@@ -1375,6 +1375,36 @@ function populateSelect(el, items, allLabel) {
if (current && items.some(it => it.key === current)) el.value = current; if (current && items.some(it => it.key === current)) el.value = current;
} }
// Checkbox-Filtergruppe (Standard für alle Filter im Portal, statt Dropdowns).
// selected ist ein Set mit den angehakten Keys. Nichts angehakt = keine
// Einschränkung. onChange wird nach jedem Toggle aufgerufen.
function renderFilterGroup(container, title, items, selected, onChange) {
if (!container) return;
const wrap = document.createElement("div");
wrap.className = "filter-group";
const lbl = document.createElement("span");
lbl.className = "filter-group-label";
lbl.textContent = title;
lbl.title = "Nichts angehakt = alle";
wrap.appendChild(lbl);
(items || []).forEach((it) => {
const label = document.createElement("label");
label.className = "filter-check";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.checked = selected.has(it.key);
cb.addEventListener("change", () => {
if (cb.checked) selected.add(it.key);
else selected.delete(it.key);
onChange();
});
label.appendChild(cb);
label.appendChild(document.createTextNode(it.label));
wrap.appendChild(label);
});
container.appendChild(wrap);
}
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
// Beim Page-Load Meta einmalig laden (asynchron, blockiert nicht) // Beim Page-Load Meta einmalig laden (asynchron, blockiert nicht)
if (window.API && (localStorage.getItem("token") || window.location.pathname === "/dashboard")) { if (window.API && (localStorage.getItem("token") || window.location.pathname === "/dashboard")) {

Datei anzeigen

@@ -5,6 +5,9 @@ let auditCache = { items: [], total: 0, offset: 0, limit: 200 };
let auditDistinct = { actions: [], resource_types: [], admins: [] }; let auditDistinct = { actions: [], resource_types: [], admins: [] };
let expandedRows = new Set(); let expandedRows = new Set();
// Checkbox-Filter (Mehrfachauswahl, leeres Set = keine Einschränkung)
const auditFilters = { actions: new Set(), resources: new Set(), admins: new Set() };
const ACTION_LABELS = { const ACTION_LABELS = {
create: "Erstellt", create: "Erstellt",
update: "Geändert", update: "Geändert",
@@ -37,20 +40,22 @@ document.addEventListener("DOMContentLoaded", () => {
tab.addEventListener("click", () => loadAudit()); tab.addEventListener("click", () => loadAudit());
}); });
// Filter-Inputs verdrahten // Filter-Inputs verdrahten (Checkbox-Gruppen laufen über renderAuditFilterBar)
["auditFilterAction", "auditFilterResource", "auditFilterResourceId", "auditFilterAdmin", ["auditFilterResourceId", "auditFilterFrom", "auditFilterTo"].forEach((id) => {
"auditFilterFrom", "auditFilterTo"].forEach((id) => {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.addEventListener("change", () => { auditCache.offset = 0; loadAudit(); }); if (el) el.addEventListener("change", () => { auditCache.offset = 0; loadAudit(); });
}); });
const reset = document.getElementById("auditFilterReset"); const reset = document.getElementById("auditFilterReset");
if (reset) reset.addEventListener("click", () => { if (reset) reset.addEventListener("click", () => {
["auditFilterAction", "auditFilterResource", "auditFilterAdmin", auditFilters.actions.clear();
"auditFilterFrom", "auditFilterTo"].forEach((id) => { auditFilters.resources.clear();
auditFilters.admins.clear();
["auditFilterResourceId", "auditFilterFrom", "auditFilterTo"].forEach((id) => {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.value = ""; if (el) el.value = "";
}); });
renderAuditFilterBar();
auditCache.offset = 0; auditCache.offset = 0;
loadAudit(); loadAudit();
}); });
@@ -70,57 +75,49 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
async function loadAuditDistinct() { async function loadAuditDistinct() {
// Distinct-Werte nur einmal laden, die Leiste nur bei Bedarf neu aufbauen
// (sonst wuerde jeder Checkbox-Klick die Leiste flackern lassen).
if (auditDistinct.actions && auditDistinct.actions.length) {
const bar = document.getElementById("auditFilterBar");
if (bar && !bar.children.length) renderAuditFilterBar();
return;
}
try { try {
auditDistinct = await API.get("/api/audit-log/distinct"); auditDistinct = await API.get("/api/audit-log/distinct");
renderAuditFilterBar();
const actSel = document.getElementById("auditFilterAction");
if (actSel && actSel.options.length <= 1) {
auditDistinct.actions.forEach((a) => {
const opt = document.createElement("option");
opt.value = a;
opt.textContent = ACTION_LABELS[a] || a;
actSel.appendChild(opt);
});
}
const resSel = document.getElementById("auditFilterResource");
if (resSel && resSel.options.length <= 1) {
auditDistinct.resource_types.forEach((r) => {
const opt = document.createElement("option");
opt.value = r;
opt.textContent = RESOURCE_LABELS[r] || r;
resSel.appendChild(opt);
});
}
const admSel = document.getElementById("auditFilterAdmin");
if (admSel && admSel.options.length <= 1) {
auditDistinct.admins.forEach((a) => {
const opt = document.createElement("option");
opt.value = a.id;
opt.textContent = a.username;
admSel.appendChild(opt);
});
}
} catch (err) { } catch (err) {
console.error("Audit-Filter laden fehlgeschlagen:", err); console.error("Audit-Filter laden fehlgeschlagen:", err);
} }
} }
// Checkbox-Filterleiste (Aktion, Ressource, Admin) aus den Distinct-Werten
function renderAuditFilterBar() {
const bar = document.getElementById("auditFilterBar");
if (!bar) return;
bar.innerHTML = "";
const onChange = () => { auditCache.offset = 0; loadAudit(); };
renderFilterGroup(bar, "Aktion",
(auditDistinct.actions || []).map((a) => ({ key: a, label: ACTION_LABELS[a] || a })),
auditFilters.actions, onChange);
renderFilterGroup(bar, "Ressource",
(auditDistinct.resource_types || []).map((r) => ({ key: r, label: RESOURCE_LABELS[r] || r })),
auditFilters.resources, onChange);
renderFilterGroup(bar, "Admin",
(auditDistinct.admins || []).map((a) => ({ key: String(a.id), label: a.username })),
auditFilters.admins, onChange);
}
async function loadAudit() { async function loadAudit() {
await loadAuditDistinct(); await loadAuditDistinct();
const params = new URLSearchParams(); const params = new URLSearchParams();
const action = document.getElementById("auditFilterAction")?.value;
const resource = document.getElementById("auditFilterResource")?.value;
const resourceId = document.getElementById("auditFilterResourceId")?.value; const resourceId = document.getElementById("auditFilterResourceId")?.value;
const adminId = document.getElementById("auditFilterAdmin")?.value;
const from = document.getElementById("auditFilterFrom")?.value; const from = document.getElementById("auditFilterFrom")?.value;
const to = document.getElementById("auditFilterTo")?.value; const to = document.getElementById("auditFilterTo")?.value;
if (action) params.append("action", action); if (auditFilters.actions.size) params.append("action", [...auditFilters.actions].join(","));
if (resource) params.append("resource_type", resource); if (auditFilters.resources.size) params.append("resource_type", [...auditFilters.resources].join(","));
if (auditFilters.admins.size) params.append("admin_id", [...auditFilters.admins].join(","));
if (resourceId) params.append("resource_id", resourceId); if (resourceId) params.append("resource_id", resourceId);
if (adminId) params.append("admin_id", adminId);
if (from) params.append("from_ts", from); if (from) params.append("from_ts", from);
if (to) params.append("to_ts", to); if (to) params.append("to_ts", to);
params.append("limit", auditCache.limit); params.append("limit", auditCache.limit);

Datei anzeigen

@@ -3,6 +3,18 @@
let suggestionsCache = []; let suggestionsCache = [];
let healthHistoryCache = []; let healthHistoryCache = [];
let classificationQueueCache = [];
// Checkbox-Filter des Aufgaben-Reiters. Leeres Set = keine Einschränkung.
const aufgabenFilters = { types: new Set(), prio: new Set(), conf: new Set() };
// Konfidenz-Stufen für den Klassifikations-Filter (Mehrfachauswahl)
const CONF_BUCKETS = [
{ key: "b85", label: "ab 85 %", test: (c) => c >= 0.85 },
{ key: "b70", label: "70 bis 85 %", test: (c) => c >= 0.7 && c < 0.85 },
{ key: "b50", label: "50 bis 70 %", test: (c) => c >= 0.5 && c < 0.7 },
{ key: "b0", label: "unter 50 %", test: (c) => c < 0.5 },
];
const SUGGESTION_TYPE_LABELS = { const SUGGESTION_TYPE_LABELS = {
add_source: "Neue Quelle", add_source: "Neue Quelle",
@@ -36,6 +48,7 @@ async function refreshTasksBadge() {
// --- Aufgaben laden --- // --- Aufgaben laden ---
async function loadAufgaben() { async function loadAufgaben() {
renderAufgabenFilters();
try { try {
const [suggestions, history] = await Promise.all([ const [suggestions, history] = await Promise.all([
API.get("/api/sources/suggestions"), API.get("/api/sources/suggestions"),
@@ -52,14 +65,36 @@ async function loadAufgaben() {
refreshTasksBadge(); refreshTasksBadge();
} }
// Checkbox-Filterleiste des Aufgaben-Reiters
function renderAufgabenFilters() {
const bar = document.getElementById("aufgabenFilterBar");
if (!bar) return;
bar.innerHTML = "";
renderFilterGroup(bar, "Vorschlagstyp",
Object.entries(SUGGESTION_TYPE_LABELS).map(([key, label]) => ({ key, label })),
aufgabenFilters.types, renderAufgabenSuggestions);
renderFilterGroup(bar, "Priorität",
Object.entries(PRIORITY_LABELS).map(([key, label]) => ({ key, label })),
aufgabenFilters.prio, renderAufgabenSuggestions);
renderFilterGroup(bar, "Konfidenz", CONF_BUCKETS.map(b => ({ key: b.key, label: b.label })),
aufgabenFilters.conf, renderClassificationList);
}
function renderAufgabenSuggestions() { function renderAufgabenSuggestions() {
const pane = document.getElementById("aufgabenSuggestions"); const pane = document.getElementById("aufgabenSuggestions");
if (!pane) return; if (!pane) return;
const pending = suggestionsCache.filter((s) => s.status === "pending"); const allPending = suggestionsCache.filter((s) => s.status === "pending");
const countEl = document.getElementById("aufgabenSuggestionCount"); const countEl = document.getElementById("aufgabenSuggestionCount");
if (countEl) countEl.textContent = String(pending.length); if (countEl) countEl.textContent = String(allPending.length);
if (pending.length === 0) { const f = aufgabenFilters;
const pending = allPending.filter((s) => {
if (f.types.size && !f.types.has(s.suggestion_type)) return false;
if (f.prio.size && !f.prio.has(s.priority)) return false;
return true;
});
if (allPending.length === 0) {
pane.innerHTML = ` pane.innerHTML = `
<div class="card" style="margin-bottom:16px;"> <div class="card" style="margin-bottom:16px;">
<div class="card-header"><h2>Vorschläge</h2></div> <div class="card-header"><h2>Vorschläge</h2></div>
@@ -67,10 +102,21 @@ function renderAufgabenSuggestions() {
</div>`; </div>`;
return; return;
} }
if (pending.length === 0) {
pane.innerHTML = `
<div class="card" style="margin-bottom:16px;">
<div class="card-header"><h2>Vorschläge (0 von ${allPending.length})</h2></div>
<div class="card-body text-muted">Keine Vorschläge mit diesen Filtern.</div>
</div>`;
return;
}
const headCount = pending.length === allPending.length
? `${allPending.length} offen`
: `${pending.length} von ${allPending.length}`;
pane.innerHTML = ` pane.innerHTML = `
<div class="card" style="margin-bottom:16px;"> <div class="card" style="margin-bottom:16px;">
<div class="card-header"> <div class="card-header">
<h2>Vorschläge (${pending.length} offen)</h2> <h2>Vorschläge (${headCount})</h2>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
@@ -271,22 +317,40 @@ const MEDIA_TYPE_LABELS = {
async function loadClassificationQueue() { async function loadClassificationQueue() {
const list = document.getElementById("classificationReviewList"); const list = document.getElementById("classificationReviewList");
if (!list) return; 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>'; list.innerHTML = '<div class="text-muted" style="padding:24px;text-align:center;">Lade…</div>';
try { try {
const items = await API.get(`/api/sources/classification/queue?limit=200&min_confidence=${minConf}`); classificationQueueCache = await API.get("/api/sources/classification/queue?limit=200&min_confidence=0");
const countEl = document.getElementById("reviewPendingCount"); renderClassificationList();
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) { } catch (err) {
list.innerHTML = `<div class="text-danger" style="padding:24px;text-align:center;">Fehler. ${esc(err.message)}</div>`; list.innerHTML = `<div class="text-danger" style="padding:24px;text-align:center;">Fehler. ${esc(err.message)}</div>`;
} }
} }
// Wendet den Konfidenz-Checkbox-Filter clientseitig auf die geladene Queue an
function renderClassificationList() {
const list = document.getElementById("classificationReviewList");
if (!list) return;
const all = classificationQueueCache || [];
const f = aufgabenFilters.conf;
const items = f.size
? all.filter((it) => {
const c = (it.proposed && it.proposed.confidence) || 0;
return CONF_BUCKETS.some((b) => f.has(b.key) && b.test(c));
})
: all;
const countEl = document.getElementById("reviewPendingCount");
if (countEl) countEl.textContent = String(all.length);
if (all.length === 0) {
list.innerHTML = '<div class="text-muted" style="padding:24px;text-align:center;">Keine ausstehenden Klassifikationen.</div>';
return;
}
if (items.length === 0) {
list.innerHTML = '<div class="text-muted" style="padding:24px;text-align:center;">Keine Klassifikationen mit diesen Filtern.</div>';
return;
}
list.innerHTML = items.map((it) => renderClassificationQueueItem(it)).join("");
}
function renderClassificationQueueItem(item) { function renderClassificationQueueItem(item) {
const cur = item.current || {}; const cur = item.current || {};
const prop = item.proposed || {}; const prop = item.proposed || {};

Datei anzeigen

@@ -5,8 +5,10 @@
let globalSourcesCache = []; let globalSourcesCache = [];
// Auswahl für Bulk-Promote (nur bei Herkunfts-Filter Kundenquellen sichtbar) // Auswahl für Bulk-Promote (nur bei Herkunfts-Filter Kundenquellen sichtbar)
let tenantSelected = new Set(); let tenantSelected = new Set();
// Aktiver Herkunfts-Filter ("" = alle, "global", "tenant") // Checkbox-Filter der Quellenliste. Leeres Set = keine Einschränkung.
let originFilter = ""; const unifiedFilters = { origin: new Set(), types: new Set(), cats: new Set(), status: new Set(), langs: new Set() };
let unifiedLangOptions = [];
let lastShowSelect = false;
// Lazy-Cache für die Health-Ausklapp-Details je Quelle // Lazy-Cache für die Health-Ausklapp-Details je Quelle
const sourceHealthDetailCache = new Map(); const sourceHealthDetailCache = new Map();
@@ -85,11 +87,6 @@ function setupSourceSubTabs() {
async function loadUnifiedSources() { async function loadUnifiedSources() {
try { try {
await ensureMeta(); await ensureMeta();
// Kategorien/Typen-Dropdowns aus META befüllen (idempotent)
if (window.META && window.META.categories && window.META.categories.length) {
populateSelect(document.getElementById("globalFilterCategory"), window.META.categories, "Alle Kategorien");
populateSelect(document.getElementById("globalFilterType"), window.META.types || [], "Alle Typen");
}
const [list, stats, languages] = await Promise.all([ const [list, stats, languages] = await Promise.all([
API.get("/api/sources?scope=all"), API.get("/api/sources?scope=all"),
API.get("/api/sources/stats"), API.get("/api/sources/stats"),
@@ -97,11 +94,7 @@ async function loadUnifiedSources() {
]); ]);
globalSourcesCache = list; globalSourcesCache = list;
sourceHealthDetailCache.clear(); sourceHealthDetailCache.clear();
populateSelect( unifiedLangOptions = (languages || []).map(l => ({ key: l, label: l }));
document.getElementById("globalFilterLanguage"),
(languages || []).map(l => ({ key: l, label: l })),
"Alle Sprachen",
);
// datalist fuer Edit-Modal // datalist fuer Edit-Modal
const dl = document.getElementById("languageSuggestions"); const dl = document.getElementById("languageSuggestions");
if (dl) { if (dl) {
@@ -112,6 +105,7 @@ async function loadUnifiedSources() {
dl.appendChild(o); dl.appendChild(o);
}); });
} }
renderUnifiedFilters();
renderUnifiedStats(stats); renderUnifiedStats(stats);
filterUnifiedSources(); filterUnifiedSources();
checkHealthRunOnLoad(); checkHealthRunOnLoad();
@@ -120,6 +114,24 @@ async function loadUnifiedSources() {
} }
} }
// Checkbox-Filterleiste der Quellenliste (Herkunft, Typ, Status, Kategorie, Sprache)
function renderUnifiedFilters() {
const bar = document.getElementById("unifiedFilterBar");
if (!bar) return;
bar.innerHTML = "";
renderFilterGroup(bar, "Herkunft", [
{ key: "global", label: "Grundquellen" },
{ key: "tenant", label: "Kundenquellen" },
], unifiedFilters.origin, filterUnifiedSources);
renderFilterGroup(bar, "Typ", (window.META && window.META.types) || [], unifiedFilters.types, filterUnifiedSources);
renderFilterGroup(bar, "Status", [
{ key: "active", label: "Aktiv" },
{ key: "inactive", label: "Inaktiv" },
], unifiedFilters.status, filterUnifiedSources);
renderFilterGroup(bar, "Kategorie", (window.META && window.META.categories) || [], unifiedFilters.cats, filterUnifiedSources);
renderFilterGroup(bar, "Sprache", unifiedLangOptions, unifiedFilters.langs, filterUnifiedSources);
}
async function showSourceAudit(sourceId, sourceName) { async function showSourceAudit(sourceId, sourceName) {
@@ -338,7 +350,9 @@ function renderUnifiedSources(sources) {
}); });
tbody.innerHTML = html; tbody.innerHTML = html;
const originLabel = originFilter === "global" ? "Grundquellen" : originFilter === "tenant" ? "Kundenquellen" : "Quellen"; const o = unifiedFilters.origin;
const originLabel = (o.has("global") && !o.has("tenant")) ? "Grundquellen"
: (o.has("tenant") && !o.has("global")) ? "Kundenquellen" : "Quellen";
document.getElementById("globalSourceCount").textContent = `${sources.length} ${originLabel}`; document.getElementById("globalSourceCount").textContent = `${sources.length} ${originLabel}`;
updateBulkButton(); updateBulkButton();
@@ -358,26 +372,19 @@ document.addEventListener("DOMContentLoaded", () => {
function filterUnifiedSources() { function filterUnifiedSources() {
const q = (document.getElementById("globalSourceSearch")?.value || "").toLowerCase(); const q = (document.getElementById("globalSourceSearch")?.value || "").toLowerCase();
const typeFilter = document.getElementById("globalFilterType")?.value || ""; const f = unifiedFilters;
const catFilter = document.getElementById("globalFilterCategory")?.value || "";
const statusFilter = document.getElementById("globalFilterStatus")?.value || "";
const langFilter = document.getElementById("globalFilterLanguage")?.value || "";
const newOrigin = document.getElementById("filterOrigin")?.value || "";
if (newOrigin !== originFilter) {
originFilter = newOrigin;
tenantSelected.clear();
}
updateOriginControls(); updateOriginControls();
let filtered = globalSourcesCache.filter((s) => { let filtered = globalSourcesCache.filter((s) => {
if (originFilter === "global" && s.tenant_id != null) return false; if (f.origin.size) {
if (originFilter === "tenant" && s.tenant_id == null) return false; const isTenant = s.tenant_id != null;
if (!((isTenant && f.origin.has("tenant")) || (!isTenant && f.origin.has("global")))) return false;
}
if (q && !(s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.url || "").toLowerCase().includes(q) || (s.bias || "").toLowerCase().includes(q) || (s.org_name || "").toLowerCase().includes(q))) return false; if (q && !(s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.url || "").toLowerCase().includes(q) || (s.bias || "").toLowerCase().includes(q) || (s.org_name || "").toLowerCase().includes(q))) return false;
if (typeFilter && s.source_type !== typeFilter) return false; if (f.types.size && !f.types.has(s.source_type)) return false;
if (catFilter && s.category !== catFilter) return false; if (f.cats.size && !f.cats.has(s.category)) return false;
if (statusFilter && s.status !== statusFilter) return false; if (f.status.size && !f.status.has(s.status)) return false;
if (langFilter && s.language !== langFilter) return false; if (f.langs.size && !f.langs.has(s.language)) return false;
return true; return true;
}); });
@@ -410,9 +417,15 @@ function sortUnifiedSources(field) {
filterUnifiedSources(); filterUnifiedSources();
} }
// Checkbox-Spalte + Bulk-Knopf nur bei Herkunfts-Filter "Kundenquellen" // Checkbox-Spalte + Bulk-Knopf nur, wenn der Herkunfts-Filter ausschließlich
// auf Kundenquellen steht. Beim Verlassen wird die Auswahl geleert.
function updateOriginControls() { function updateOriginControls() {
const showSelect = originFilter === "tenant"; const o = unifiedFilters.origin;
const showSelect = o.has("tenant") && !o.has("global");
if (!showSelect && lastShowSelect) {
tenantSelected.clear();
}
lastShowSelect = showSelect;
const table = document.getElementById("unifiedTable"); const table = document.getElementById("unifiedTable");
if (table) table.classList.toggle("show-select", showSelect); if (table) table.classList.toggle("show-select", showSelect);
const btn = document.getElementById("tenantBulkPromoteBtn"); const btn = document.getElementById("tenantBulkPromoteBtn");