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
6 geänderte Dateien mit 314 neuen und 288 gelöschten Zeilen
Nur Änderungen aus Commit bbbcc51fc2 werden angezeigt - Alle Commits anzeigen

Datei anzeigen

@@ -144,18 +144,27 @@ class GlobalSourceUpdate(BaseModel):
alignments: Optional[list[str]] = None
@router.get("/global")
async def list_global_sources(
@router.get("")
async def list_sources(
scope: str = "all",
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle Grundquellen auflisten (tenant_id IS NULL).
"""Vereinte Quellenliste (Grund- und Kundenquellen).
Liefert pro Quelle den worst-case Health-Status aus source_health_checks
(error > warning > ok > unknown). Damit kann das Frontend ein Inline-Badge
pro Zeile zeigen, ohne separate Health-Tab-Abfrage.
scope = all | global | tenant. Liefert pro Quelle den worst-case
Health-Status aus source_health_checks (error > warning > ok > unknown)
und den Organisationsnamen bei Kundenquellen (org_name, NULL bei global).
"""
cursor = await db.execute("""
scope_where = {
"all": "1=1",
"global": "s.tenant_id IS NULL",
"tenant": "s.tenant_id IS NOT NULL",
}.get(scope)
if scope_where is None:
raise HTTPException(status_code=422, detail="scope muss all, global oder tenant sein")
cursor = await db.execute(f"""
WITH article_stats AS (
-- Match per source-Name (case-insensitive). source_url im articles ist die
-- Artikel-URL, nicht die Feed-URL - daher matcht das nicht mit sources.url.
@@ -177,6 +186,7 @@ async def list_global_sources(
GROUP BY source_id
)
SELECT s.*,
o.name AS org_name,
CASE ha.rank
WHEN 3 THEN 'error'
WHEN 2 THEN 'warning'
@@ -186,16 +196,13 @@ async def list_global_sources(
COALESCE(ast.a7d, 0) AS articles_7d,
COALESCE(ast.a30d, 0) AS articles_30d
FROM sources s
LEFT JOIN organizations o ON o.id = s.tenant_id
LEFT JOIN article_stats ast ON ast.s_lower = LOWER(s.name)
LEFT JOIN health_agg ha ON ha.source_id = s.id
WHERE s.tenant_id IS NULL
WHERE {scope_where}
ORDER BY s.category, s.source_type, s.name
""")
rows = [dict(row) for row in await cursor.fetchall()]
alignments_map = await _load_alignments_for(db, [r["id"] for r in rows])
for r in rows:
r["alignments"] = alignments_map.get(r["id"], [])
return rows
return [dict(row) for row in await cursor.fetchall()]
@router.post("/global", status_code=201)
@@ -245,13 +252,17 @@ async def update_global_source(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Grundquelle bearbeiten — inkl. Klassifikation + alignments."""
"""Quelle bearbeiten (Grund- und Kundenquellen) — inkl. Klassifikation + alignments.
Kundenquellen sind absichtlich mit erlaubt, damit der Deaktivieren-Knopf
der vereinten Liste (status=inactive) fuer beide Herkuenfte funktioniert.
"""
cursor = await db.execute(
"SELECT * FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,)
"SELECT * FROM sources WHERE id = ?", (source_id,)
)
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden")
raise HTTPException(status_code=404, detail="Quelle nicht gefunden")
before = dict(row)
before_alignments = sorted((await _load_alignments_for(db, [source_id])).get(source_id, []))
@@ -336,31 +347,35 @@ async def delete_global_source(
@router.get("/global/languages")
async def get_global_languages(
@router.get("/languages")
async def get_languages(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Distinct language-Werte aus Grundquellen - für Frontend-Filter-Dropdown."""
"""Distinct language-Werte aller Quellen - für Frontend-Filter-Dropdown."""
cur = await db.execute("""
SELECT DISTINCT language
FROM sources
WHERE tenant_id IS NULL AND language IS NOT NULL AND language != ''
WHERE language IS NOT NULL AND language != ''
ORDER BY language
""")
return [r["language"] for r in await cur.fetchall()]
@router.get("/global/stats")
async def get_global_stats(
@router.get("/stats")
async def get_sources_stats(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Aggregierte Stats für die Grundquellen-Stats-Bar oben im Tab."""
"""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.
"""
cur = await db.execute("""
SELECT source_type, COUNT(*) AS count, COALESCE(SUM(article_count), 0) AS articles
FROM sources
WHERE tenant_id IS NULL AND status = 'active'
WHERE status = 'active'
GROUP BY source_type
""")
by_type = {}
@@ -372,8 +387,18 @@ async def get_global_stats(
total += d["count"]
total_articles += d["articles"]
# Health-Counter
cur = await db.execute("""
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()}
# Health-Counter + Zeitpunkt des letzten Checks
health = {"errors": 0, "warnings": 0, "ok": 0}
last_check = None
cur = await db.execute("""
SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks'
""")
@@ -382,7 +407,7 @@ async def get_global_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.tenant_id IS NULL AND s.status = 'active'
WHERE s.status = 'active'
GROUP BY h.status
""")
for r in await cur.fetchall():
@@ -393,31 +418,21 @@ async def get_global_stats(
health["warnings"] = d["cnt"]
elif d["hs"] == "ok":
health["ok"] = d["cnt"]
cur = await db.execute("SELECT MAX(checked_at) AS lc FROM source_health_checks")
row = await cur.fetchone()
if row:
last_check = dict(row)["lc"]
return {
"by_type": by_type,
"by_origin": by_origin,
"total": total,
"total_articles": total_articles,
"health": health,
"last_check": last_check,
}
@router.get("/tenant")
async def list_tenant_sources(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle tenant-spezifischen Quellen mit Org-Name auflisten."""
cursor = await db.execute("""
SELECT s.*, o.name as org_name
FROM sources s
LEFT JOIN organizations o ON o.id = s.tenant_id
WHERE s.tenant_id IS NOT NULL
ORDER BY o.name, s.category, s.name
""")
return [dict(row) for row in await cursor.fetchall()]
@router.post("/tenant/{source_id}/promote")
async def promote_to_global(
source_id: int,
@@ -799,6 +814,36 @@ async def get_health_history(
return [dict(row) for row in await cursor.fetchall()]
@router.get("/{source_id}/health")
async def get_source_health(
source_id: int,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle Health-Checks einer einzelnen Quelle (für die Ausklapp-Zeile der Liste).
Sortiert error > warning > ok, damit das Wichtigste oben steht.
"""
cursor = await db.execute("SELECT id FROM sources WHERE id = ?", (source_id,))
if not await cursor.fetchone():
raise HTTPException(status_code=404, detail="Quelle nicht gefunden")
cursor = await db.execute("""
SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks'
""")
if not await cursor.fetchone():
return {"source_id": source_id, "checks": []}
cursor = await db.execute("""
SELECT check_type, status, message, checked_at
FROM source_health_checks
WHERE source_id = ?
ORDER BY CASE status WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
check_type
""", (source_id,))
return {"source_id": source_id, "checks": [dict(row) for row in await cursor.fetchall()]}
@router.get("/suggestions")
async def get_suggestions(
admin: dict = Depends(get_current_admin),

Datei anzeigen

@@ -920,6 +920,34 @@ input[type="date"].filter-select { padding: 6px 10px; }
.health-badge-ok { background: rgba(16, 185, 129, 0.15); color: #10b981; }
.health-badge-unknown { background: rgba(148, 163, 184, 0.15); color: #94a3b8; }
/* Klickbares Health-Badge oeffnet die Detailzeile */
.health-badge-btn { cursor: pointer; }
.health-badge-btn:hover { filter: brightness(1.25); text-decoration: underline; }
/* Checkbox-Spalte: nur sichtbar bei Herkunfts-Filter "Kundenquellen" */
.select-col { display: none; }
table.show-select .select-col { display: table-cell; }
/* Health-Details in der Ausklapp-Zeile */
.src-notes-text { margin-bottom: 8px; }
.health-detail-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 4px 0 8px;
}
.health-detail-row {
display: flex;
align-items: baseline;
gap: 12px;
padding: 3px 0;
font-size: 13px;
}
.health-detail-type { min-width: 120px; font-weight: 600; }
.health-detail-msg { flex: 1; }
.health-detail-ts { white-space: nowrap; font-size: 12px; }
/* === Audit-Spur (Phase 5) === */
.modal.modal-large {
max-width: 720px;

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=20260725h">
<link rel="stylesheet" href="/static/css/style.css?v=20260725i">
<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>
@@ -285,55 +285,62 @@
<!-- Sources Section -->
<div class="section" id="sec-sources">
<div class="nav-tabs" id="sourceSubTabs">
<button class="nav-tab active" data-subtab="global-sources">Grundquellen</button>
<button class="nav-tab" data-subtab="tenant-sources">Kundenquellen</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="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>
</div>
<!-- Grundquellen -->
<!-- Vereinte Quellenliste (Grund- + Kundenquellen) -->
<div class="section active" id="sub-global-sources">
<div class="sources-stats-bar" id="globalStatsBar"></div>
<div class="action-bar">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<input type="text" class="search-input" id="globalSourceSearch" placeholder="Grundquelle suchen...">
<select class="filter-select" id="globalFilterType" onchange="filterGlobalSources()">
<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="filterGlobalSources()">
<select class="filter-select" id="globalFilterCategory" onchange="filterUnifiedSources()">
<option value="">Alle Kategorien</option>
</select>
<select class="filter-select" id="globalFilterStatus" onchange="filterGlobalSources()">
<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="filterGlobalSources()">
<select class="filter-select" id="globalFilterLanguage" onchange="filterUnifiedSources()">
<option value="">Alle Sprachen</option>
</select>
<span class="text-secondary" id="globalSourceCount"></span>
</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-secondary" id="discoverSourceBtn">Erkennen</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>
</div>
<div class="card">
<div class="table-wrap">
<table>
<table id="unifiedTable">
<thead>
<tr>
<th class="sortable" data-sort="name" onclick="sortGlobalSources('name')">Name <span class="sort-icon"></span></th>
<th class="select-col"><input type="checkbox" id="unifiedSelectAll" onchange="toggleTenantSelectAll(this.checked)"></th>
<th class="sortable" data-sort="name" onclick="sortUnifiedSources('name')">Name <span class="sort-icon"></span></th>
<th>URL</th>
<th class="sortable" data-sort="domain" onclick="sortGlobalSources('domain')">Domain <span class="sort-icon"></span></th>
<th class="sortable" data-sort="source_type" onclick="sortGlobalSources('source_type')">Typ <span class="sort-icon"></span></th>
<th class="sortable" data-sort="article_count" onclick="sortGlobalSources('article_count')">Artikel <span class="sort-icon"></span></th>
<th class="sortable" data-sort="articles_30d" onclick="sortGlobalSources('articles_30d')">Aktivität <span class="sort-icon"></span></th>
<th class="sortable" data-sort="language" onclick="sortGlobalSources('language')">Sprache <span class="sort-icon"></span></th>
<th class="sortable" data-sort="domain" onclick="sortUnifiedSources('domain')">Domain <span class="sort-icon"></span></th>
<th class="sortable" data-sort="source_type" onclick="sortUnifiedSources('source_type')">Typ <span class="sort-icon"></span></th>
<th class="sortable" data-sort="article_count" onclick="sortUnifiedSources('article_count')">Artikel <span class="sort-icon"></span></th>
<th class="sortable" data-sort="articles_30d" onclick="sortUnifiedSources('articles_30d')">Aktivität <span class="sort-icon"></span></th>
<th class="sortable" data-sort="language" onclick="sortUnifiedSources('language')">Sprache <span class="sort-icon"></span></th>
<th>Bias</th>
<th class="sortable" data-sort="last_seen_at" onclick="sortGlobalSources('last_seen_at')">Letzter Treffer <span class="sort-icon"></span></th>
<th class="sortable" data-sort="health_status" onclick="sortGlobalSources('health_status')">Health <span class="sort-icon"></span></th>
<th class="sortable" data-sort="status" onclick="sortGlobalSources('status')">Status <span class="sort-icon"></span></th>
<th class="sortable" data-sort="last_seen_at" onclick="sortUnifiedSources('last_seen_at')">Letzter Treffer <span class="sort-icon"></span></th>
<th class="sortable" data-sort="health_status" onclick="sortUnifiedSources('health_status')">Health <span class="sort-icon"></span></th>
<th class="sortable" data-sort="status" onclick="sortUnifiedSources('status')">Status <span class="sort-icon"></span></th>
<th class="sortable" data-sort="org_name" onclick="sortUnifiedSources('org_name')">Organisation <span class="sort-icon"></span></th>
<th>Aktionen</th>
</tr>
</thead>
@@ -343,52 +350,6 @@
</div>
</div>
<!-- Kundenquellen -->
<div class="section" id="sub-tenant-sources">
<div class="action-bar">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<input type="text" class="search-input" id="tenantSourceSearch" placeholder="Kundenquelle suchen...">
<select class="filter-select" id="tenantFilterType" onchange="filterTenantSources()">
<option value="">Alle Typen</option>
</select>
<select class="filter-select" id="tenantFilterCategory" onchange="filterTenantSources()">
<option value="">Alle Kategorien</option>
</select>
<select class="filter-select" id="tenantFilterOrg" onchange="filterTenantSources()">
<option value="">Alle Organisationen</option>
</select>
<select class="filter-select" id="tenantFilterLanguage" onchange="filterTenantSources()">
<option value="">Alle Sprachen</option>
</select>
<span class="text-secondary" id="tenantSourceCount"></span>
</div>
<button class="btn btn-primary" id="tenantBulkPromoteBtn" disabled onclick="bulkPromoteSelected()">
Ausgewählte übernehmen (0)
</button>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:32px;"><input type="checkbox" id="tenantSelectAll" onchange="toggleTenantSelectAll(this.checked)"></th>
<th class="sortable" data-sort="name" onclick="sortTenantSources('name')">Name <span class="sort-icon"></span></th>
<th class="sortable" data-sort="domain" onclick="sortTenantSources('domain')">Domain <span class="sort-icon"></span></th>
<th class="sortable" data-sort="source_type" onclick="sortTenantSources('source_type')">Typ <span class="sort-icon"></span></th>
<th class="sortable" data-sort="category" onclick="sortTenantSources('category')">Kategorie <span class="sort-icon"></span></th>
<th class="sortable" data-sort="org_name" onclick="sortTenantSources('org_name')">Organisation <span class="sort-icon"></span></th>
<th class="sortable" data-sort="language" onclick="sortTenantSources('language')">Sprache <span class="sort-icon"></span></th>
<th>Bias</th>
<th>Hinzugefügt von</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="tenantSourceTable"></tbody>
</table>
</div>
</div>
</div>
<!-- Quellen-Health (Sub-Tab) - drei Bereiche als Sub-Sub-Tabs;
source-health.js rendert pro Bereich in den jeweiligen Container. -->
<div class="section" id="sub-source-health">
@@ -1055,9 +1016,9 @@
<script src="/static/js/a11y.js?v=20260725a"></script>
<script src="/static/js/app.js?v=20260725r"></script>
<script src="/static/js/sources.js?v=20260725a"></script>
<script src="/static/js/sources.js?v=20260725b"></script>
<script src="/static/js/x-scraper.js?v=20260522a"></script>
<script src="/static/js/source-health.js?v=20260725a"></script>
<script src="/static/js/source-health.js?v=20260725b"></script>
<script src="/static/js/audit.js?v=20260509d"></script>
<div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div>
</body>

Datei anzeigen

@@ -19,12 +19,7 @@ const HEALTH_CACHE_TTL_MS = 60000;
let healthLoadLimit = 100;
const CHECK_TYPE_LABELS = {
reachability: "Erreichbarkeit",
feed_validity: "Feed-Validität",
stale: "Aktualität",
duplicate: "Duplikat",
};
// CHECK_TYPE_LABELS kommt global aus sources.js (lädt vor dieser Datei)
const SUGGESTION_TYPE_LABELS = {
add_source: "Neue Quelle",
@@ -423,7 +418,7 @@ async function handleSuggestion(id, accept) {
}
loadHealthData(true);
// Grundquellen-Liste auch aktualisieren
if (typeof loadGlobalSources === "function") loadGlobalSources();
if (typeof loadUnifiedSources === "function") loadUnifiedSources();
} catch (err) {
showToast("Fehler: " + err.message, "error");
}

Datei anzeigen

@@ -1,17 +1,26 @@
/* Grundquellen & Kundenquellen Management */
/* Vereinte Quellenliste (Grund- + Kundenquellen) */
"use strict";
// Hält Grund- UND Kundenquellen (GET /api/sources?scope=all)
let globalSourcesCache = [];
let tenantSourcesCache = [];
// Phase 3c: Tenant-Tab State
let tenantFilters = { search: "", type: "", category: "", org: "", language: "" };
let tenantSort = { field: "org_name", asc: true };
// Auswahl für Bulk-Promote (nur bei Herkunfts-Filter Kundenquellen sichtbar)
let tenantSelected = new Set();
// Aktiver Herkunfts-Filter ("" = alle, "global", "tenant")
let originFilter = "";
// Lazy-Cache für die Health-Ausklapp-Details je Quelle
const sourceHealthDetailCache = new Map();
let editingSourceId = null;
let globalSortField = "category";
let globalSortAsc = true;
const CHECK_TYPE_LABELS = {
reachability: "Erreichbarkeit",
feed_validity: "Feed-Validität",
stale: "Aktualität",
duplicate: "Duplikat",
};
// CATEGORY_LABELS jetzt global (aus app.js loadMeta)
// TYPE_LABELS jetzt global (aus app.js loadMeta)
@@ -49,7 +58,7 @@ document.addEventListener("DOMContentLoaded", () => {
// Beim Tab-Wechsel auf "Quellen" laden
document.querySelectorAll('.nav-tab[data-section="sources"]').forEach((tab) => {
tab.addEventListener("click", () => loadGlobalSources());
tab.addEventListener("click", () => loadUnifiedSources());
});
});
@@ -62,8 +71,7 @@ function setupSourceSubTabs() {
document.querySelectorAll("#sec-sources > .section").forEach((s) => s.classList.remove("active"));
document.getElementById("sub-" + subtab).classList.add("active");
if (subtab === "global-sources") loadGlobalSources();
else if (subtab === "tenant-sources") loadTenantSources();
if (subtab === "global-sources") loadUnifiedSources();
else if (subtab === "source-health") loadHealthData();
else if (subtab === "classification-review") loadClassificationQueue();
else if (subtab === "x-scraper") loadXScraperAccounts();
@@ -71,8 +79,8 @@ function setupSourceSubTabs() {
});
}
// --- Grundquellen ---
async function loadGlobalSources() {
// --- Vereinte Quellenliste ---
async function loadUnifiedSources() {
try {
await ensureMeta();
// Kategorien/Typen-Dropdowns aus META befüllen (idempotent)
@@ -81,21 +89,17 @@ async function loadGlobalSources() {
populateSelect(document.getElementById("globalFilterType"), window.META.types || [], "Alle Typen");
}
const [list, stats, languages] = await Promise.all([
API.get("/api/sources/global"),
API.get("/api/sources/global/stats"),
API.get("/api/sources/global/languages").catch(() => []),
API.get("/api/sources?scope=all"),
API.get("/api/sources/stats"),
API.get("/api/sources/languages").catch(() => []),
]);
globalSourcesCache = list;
sourceHealthDetailCache.clear();
populateSelect(
document.getElementById("globalFilterLanguage"),
(languages || []).map(l => ({ key: l, label: l })),
"Alle Sprachen",
);
populateSelect(
document.getElementById("tenantFilterLanguage"),
(languages || []).map(l => ({ key: l, label: l })),
"Alle Sprachen",
);
// datalist fuer Edit-Modal
const dl = document.getElementById("languageSuggestions");
if (dl) {
@@ -106,10 +110,10 @@ async function loadGlobalSources() {
dl.appendChild(o);
});
}
renderGlobalStats(stats);
renderGlobalSources(globalSourcesCache);
renderUnifiedStats(stats);
filterUnifiedSources();
} catch (err) {
console.error("Grundquellen laden fehlgeschlagen:", err);
console.error("Quellenliste laden fehlgeschlagen:", err);
}
}
@@ -164,7 +168,7 @@ function formatDateTime(iso) {
} catch { return iso; }
}
function renderGlobalStats(stats) {
function renderUnifiedStats(stats) {
const bar = document.getElementById("globalStatsBar");
if (!bar) return;
if (!stats || !stats.by_type) { bar.innerHTML = ""; return; }
@@ -172,6 +176,11 @@ function renderGlobalStats(stats) {
const types = window.META && window.META.types ? window.META.types : [];
const parts = [];
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${stats.total || 0}</span> Quellen gesamt</span>`);
const bo = stats.by_origin || {};
if (bo.tenant) {
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${bo.global || 0}</span> Grundquellen</span>`);
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${bo.tenant}</span> Kundenquellen</span>`);
}
for (const t of types) {
const v = stats.by_type[t.key] || { count: 0, articles: 0 };
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${v.count}</span> ${esc(t.label)}</span>`);
@@ -186,11 +195,12 @@ function renderGlobalStats(stats) {
bar.innerHTML = parts.join("");
}
function renderGlobalSources(sources) {
function renderUnifiedSources(sources) {
const tbody = document.getElementById("globalSourceTable");
const cols = 12;
const cols = 14;
if (sources.length === 0) {
tbody.innerHTML = `<tr><td colspan="${cols}" class="text-muted">Keine Grundquellen</td></tr>`;
tbody.innerHTML = `<tr><td colspan="${cols}" class="text-muted">Keine Quellen</td></tr>`;
updateBulkButton();
return;
}
@@ -209,18 +219,36 @@ function renderGlobalSources(sources) {
const count = grouped[cat].length;
html += `<tr class="cat-header-row"><td colspan="${cols}"><span class="cat-header-label">${esc(label)}</span><span class="cat-header-count">${count}</span></td></tr>`;
grouped[cat].forEach((s) => {
const isTenant = s.tenant_id != null;
const hasNotes = s.notes && s.notes.trim();
const infoBtn = hasNotes
? `<span class="src-info-toggle" onclick="toggleSourceInfo(${s.id})" title="Info einblenden">&#9432;</span>`
: '';
const notesRow = hasNotes
? `<tr class="src-notes-row" id="notes-${s.id}" style="display:none;"><td colspan="${cols}" class="src-notes-cell">${esc(s.notes)}</td></tr>`
// EINE gemeinsame Detailzeile je Quelle: Notizen + Health-Details.
const hasDetail = hasNotes || s.health_status;
const detailRow = hasDetail
? `<tr class="src-notes-row" id="detail-${s.id}" style="display:none;"><td colspan="${cols}" class="src-notes-cell">${hasNotes ? `<div class="src-notes-text">${esc(s.notes)}</div>` : ""}${s.health_status ? `<div class="health-detail" id="health-detail-${s.id}"></div>` : ""}</td></tr>`
: '';
const lastSeen = s.last_seen_at ? formatDate(s.last_seen_at) : "-";
const hs = s.health_status || "unknown";
const hsLabel = { error: "Fehler", warning: "Warnung", ok: "OK", unknown: "—" }[hs];
const hsClass = "health-badge-" + (hs === "unknown" ? "unknown" : hs);
const healthCell = s.health_status
? `<span class="health-badge ${hsClass} health-badge-btn" onclick="toggleHealthDetail(${s.id})" title="Health-Details anzeigen" role="button" tabindex="0">${hsLabel}</span>`
: `<span class="health-badge ${hsClass}">${hsLabel}</span>`;
const selectCell = isTenant
? `<td class="select-col"><input type="checkbox" class="tenant-select" data-id="${s.id}" ${tenantSelected.has(s.id) ? "checked" : ""} onchange="toggleTenantSelect(${s.id}, this.checked)"></td>`
: `<td class="select-col"></td>`;
// Kundenquellen sind bewusst nicht editierbar: Workflow ist erst
// "Übernehmen" (Promote zur Grundquelle), dann als Grundquelle bearbeiten.
const actions = isTenant
? `<button class="btn btn-primary btn-small" onclick="promoteSource(${s.id}, '${esc(s.name)}')">Übernehmen</button>
<button class="btn btn-secondary btn-small" onclick="showSourceAudit(${s.id}, '${esc(s.name)}')">Audit</button>`
: `<button class="btn btn-secondary btn-small" onclick="editGlobalSource(${s.id})">Bearbeiten</button>
<button class="btn btn-secondary btn-small" onclick="showSourceAudit(${s.id}, '${esc(s.name)}')">Audit</button>
<button class="btn btn-danger btn-small" onclick="confirmDeleteGlobalSource(${s.id}, '${esc(s.name)}')">Löschen</button>`;
html += `<tr>
${selectCell}
<td>${infoBtn} ${esc(s.name)}</td>
<td class="text-secondary" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.url || '')}">${esc(s.url || "-")}</td>
<td>${esc(s.domain || "-")}</td>
@@ -230,23 +258,22 @@ function renderGlobalSources(sources) {
<td class="text-secondary">${esc(s.language || "-")}</td>
<td class="text-secondary" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.bias || "")}">${esc(s.bias || "-")}</td>
<td class="text-secondary">${lastSeen}</td>
<td><span class="health-badge ${hsClass}">${hsLabel}</span></td>
<td>${healthCell}</td>
<td><span class="badge badge-${s.status === "active" ? "active" : "inactive"}">${s.status === "active" ? "Aktiv" : "Inaktiv"}</span></td>
<td>
<button class="btn btn-secondary btn-small" onclick="editGlobalSource(${s.id})">Bearbeiten</button>
<button class="btn btn-secondary btn-small" onclick="showSourceAudit(${s.id}, '${esc(s.name)}')">Audit</button>
<button class="btn btn-danger btn-small" onclick="confirmDeleteGlobalSource(${s.id}, '${esc(s.name)}')">Löschen</button>
</td>
</tr>${notesRow}`;
<td class="text-secondary">${isTenant ? esc(s.org_name || ("Org " + s.tenant_id)) : '<span class="text-muted">—</span>'}</td>
<td>${actions}</td>
</tr>${detailRow}`;
});
});
tbody.innerHTML = html;
document.getElementById("globalSourceCount").textContent = `${sources.length} Grundquellen`;
const originLabel = originFilter === "global" ? "Grundquellen" : originFilter === "tenant" ? "Kundenquellen" : "Quellen";
document.getElementById("globalSourceCount").textContent = `${sources.length} ${originLabel}`;
updateBulkButton();
// Sort-Icons aktualisieren
document.querySelectorAll("th.sortable .sort-icon").forEach(el => el.textContent = "");
const activeHeader = document.querySelector(`th.sortable[data-sort="${globalSortField}"] .sort-icon`);
document.querySelectorAll("#unifiedTable th.sortable .sort-icon").forEach(el => el.textContent = "");
const activeHeader = document.querySelector(`#unifiedTable th.sortable[data-sort="${globalSortField}"] .sort-icon`);
if (activeHeader) activeHeader.textContent = globalSortAsc ? " ▲" : " ▼";
}
@@ -254,19 +281,28 @@ function renderGlobalSources(sources) {
document.addEventListener("DOMContentLoaded", () => {
const el = document.getElementById("globalSourceSearch");
if (el) {
el.addEventListener("input", () => filterGlobalSources());
el.addEventListener("input", () => filterUnifiedSources());
}
});
function filterGlobalSources() {
function filterUnifiedSources() {
const q = (document.getElementById("globalSourceSearch")?.value || "").toLowerCase();
const typeFilter = document.getElementById("globalFilterType")?.value || "";
const catFilter = document.getElementById("globalFilterCategory")?.value || "";
const statusFilter = document.getElementById("globalFilterStatus")?.value || "";
const langFilter = document.getElementById("globalFilterLanguage")?.value || "";
const newOrigin = document.getElementById("filterOrigin")?.value || "";
if (newOrigin !== originFilter) {
originFilter = newOrigin;
tenantSelected.clear();
}
updateOriginControls();
let filtered = globalSourcesCache.filter((s) => {
if (q && !(s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.url || "").toLowerCase().includes(q) || (s.bias || "").toLowerCase().includes(q))) return false;
if (originFilter === "global" && s.tenant_id != null) return false;
if (originFilter === "tenant" && s.tenant_id == null) return false;
if (q && !(s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.url || "").toLowerCase().includes(q) || (s.bias || "").toLowerCase().includes(q) || (s.org_name || "").toLowerCase().includes(q))) return false;
if (typeFilter && s.source_type !== typeFilter) return false;
if (catFilter && s.category !== catFilter) return false;
if (statusFilter && s.status !== statusFilter) return false;
@@ -290,17 +326,26 @@ function filterGlobalSources() {
return globalSortAsc ? cmp : -cmp;
});
renderGlobalSources(filtered);
renderUnifiedSources(filtered);
}
function sortGlobalSources(field) {
function sortUnifiedSources(field) {
if (globalSortField === field) {
globalSortAsc = !globalSortAsc;
} else {
globalSortField = field;
globalSortAsc = true;
}
filterGlobalSources();
filterUnifiedSources();
}
// Checkbox-Spalte + Bulk-Knopf nur bei Herkunfts-Filter "Kundenquellen"
function updateOriginControls() {
const showSelect = originFilter === "tenant";
const table = document.getElementById("unifiedTable");
if (table) table.classList.toggle("show-select", showSelect);
const btn = document.getElementById("tenantBulkPromoteBtn");
if (btn) btn.style.display = showSelect ? "" : "none";
}
// --- Grundquelle erstellen/bearbeiten ---
@@ -392,7 +437,7 @@ function setupSourceForms() {
await API.post("/api/sources/global", body);
}
closeModal("modalSource");
loadGlobalSources();
loadUnifiedSources();
} catch (err) {
errEl.textContent = err.message;
errEl.style.display = "block";
@@ -417,7 +462,7 @@ function confirmDeleteGlobalSource(id, name) {
async () => {
try {
await API.del("/api/sources/global/" + id);
loadGlobalSources();
loadUnifiedSources();
} catch (err) {
showToast(err.message, "error");
}
@@ -425,84 +470,9 @@ function confirmDeleteGlobalSource(id, name) {
);
}
// --- Kundenquellen ---
async function loadTenantSources() {
try {
tenantSourcesCache = await API.get("/api/sources/tenant");
tenantSelected.clear();
populateTenantFilters();
applyTenantFilterAndSort();
} catch (err) {
console.error("Kundenquellen laden fehlgeschlagen:", err);
showToast("Kundenquellen konnten nicht geladen werden", "error");
}
}
function populateTenantFilters() {
// Typ + Kategorie aus META, Org aus Cache
if (window.META && window.META.types) {
populateSelect(document.getElementById("tenantFilterType"), window.META.types, "Alle Typen");
}
if (window.META && window.META.categories) {
populateSelect(document.getElementById("tenantFilterCategory"),
window.META.categories, "Alle Kategorien");
}
// Org-Liste aus den Daten extrahieren (eindeutig)
const orgs = Array.from(new Set(tenantSourcesCache.map(s => s.org_name).filter(Boolean))).sort();
populateSelect(
document.getElementById("tenantFilterOrg"),
orgs.map(o => ({ key: o, label: o })),
"Alle Organisationen",
);
}
function applyTenantFilterAndSort() {
const q = (tenantFilters.search || "").toLowerCase();
let filtered = tenantSourcesCache.filter(s => {
if (q && !(
(s.name || "").toLowerCase().includes(q)
|| (s.domain || "").toLowerCase().includes(q)
|| (s.org_name || "").toLowerCase().includes(q)
|| (s.url || "").toLowerCase().includes(q)
)) return false;
if (tenantFilters.type && s.source_type !== tenantFilters.type) return false;
if (tenantFilters.category && s.category !== tenantFilters.category) return false;
if (tenantFilters.org && s.org_name !== tenantFilters.org) return false;
if (tenantFilters.language && s.language !== tenantFilters.language) return false;
return true;
});
filtered.sort((a, b) => {
const va = String(a[tenantSort.field] ?? "").toLowerCase();
const vb = String(b[tenantSort.field] ?? "").toLowerCase();
const cmp = va.localeCompare(vb, "de");
return tenantSort.asc ? cmp : -cmp;
});
renderTenantSources(filtered);
// Sort-Icons aktualisieren
document.querySelectorAll("#sub-tenant-sources th.sortable .sort-icon").forEach(el => el.textContent = "");
const active = document.querySelector(`#sub-tenant-sources th.sortable[data-sort="${tenantSort.field}"] .sort-icon`);
if (active) active.textContent = tenantSort.asc ? " \u25B2" : " \u25BC";
}
function filterTenantSources() {
tenantFilters.search = (document.getElementById("tenantSourceSearch")?.value || "").trim();
tenantFilters.type = document.getElementById("tenantFilterType")?.value || "";
tenantFilters.category = document.getElementById("tenantFilterCategory")?.value || "";
tenantFilters.org = document.getElementById("tenantFilterOrg")?.value || "";
tenantFilters.language = document.getElementById("tenantFilterLanguage")?.value || "";
applyTenantFilterAndSort();
}
function sortTenantSources(field) {
if (tenantSort.field === field) tenantSort.asc = !tenantSort.asc;
else { tenantSort.field = field; tenantSort.asc = true; }
applyTenantFilterAndSort();
}
// --- Bulk-Promote (Kundenquellen in der vereinten Liste) ---
function toggleTenantSelectAll(checked) {
document.querySelectorAll("#tenantSourceTable input.tenant-select").forEach(cb => {
document.querySelectorAll("#globalSourceTable input.tenant-select").forEach(cb => {
cb.checked = checked;
const id = parseInt(cb.dataset.id);
if (checked) tenantSelected.add(id); else tenantSelected.delete(id);
@@ -515,9 +485,9 @@ function toggleTenantSelect(id, checked) {
if (checked) tenantSelected.add(id); else tenantSelected.delete(id);
updateBulkButton();
// Header-Checkbox anpassen
const visible = document.querySelectorAll("#tenantSourceTable input.tenant-select").length;
const checkedVisible = document.querySelectorAll("#tenantSourceTable input.tenant-select:checked").length;
const all = document.getElementById("tenantSelectAll");
const visible = document.querySelectorAll("#globalSourceTable input.tenant-select").length;
const checkedVisible = document.querySelectorAll("#globalSourceTable input.tenant-select:checked").length;
const all = document.getElementById("unifiedSelectAll");
if (all) all.checked = visible > 0 && visible === checkedVisible;
}
@@ -544,50 +514,12 @@ async function bulkPromoteSelected() {
if (result.failed && result.failed.length) msg += `, ${result.failed.length} Fehler`;
showToast(msg, result.failed && result.failed.length ? "warning" : "success");
tenantSelected.clear();
await loadTenantSources();
await loadUnifiedSources();
} catch (err) {
showToast("Bulk-Promote fehlgeschlagen: " + err.message, "error");
}
}
function renderTenantSources(sources) {
const tbody = document.getElementById("tenantSourceTable");
const cols = 10;
if (sources.length === 0) {
tbody.innerHTML = `<tr><td colspan="${cols}" class="text-muted">Keine Kundenquellen</td></tr>`;
document.getElementById("tenantSourceCount").textContent = `0 / ${tenantSourcesCache.length} Kundenquellen`;
updateBulkButton();
return;
}
tbody.innerHTML = sources.map((s) => {
const checked = tenantSelected.has(s.id) ? "checked" : "";
return `
<tr>
<td><input type="checkbox" class="tenant-select" data-id="${s.id}" ${checked} onchange="toggleTenantSelect(${s.id}, this.checked)"></td>
<td>${esc(s.name)}</td>
<td class="text-secondary" style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.url || '')}">${esc(s.domain || "-")}</td>
<td>${typeLabel(s.source_type)}</td>
<td>${categoryLabel(s.category)}</td>
<td>${esc(s.org_name || "-")}</td>
<td class="text-secondary">${esc(s.language || "-")}</td>
<td class="text-secondary" style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.bias || "")}">${esc(s.bias || "-")}</td>
<td>${esc(s.added_by || "-")}</td>
<td>
<button class="btn btn-primary btn-small" onclick="promoteSource(${s.id}, '${esc(s.name)}')">Übernehmen</button>
</td>
</tr>`;
}).join("");
document.getElementById("tenantSourceCount").textContent = `${sources.length} / ${tenantSourcesCache.length} Kundenquellen`;
updateBulkButton();
}
// Suche Kundenquellen
document.addEventListener("DOMContentLoaded", () => {
const el = document.getElementById("tenantSourceSearch");
if (el) el.addEventListener("input", () => filterTenantSources());
});
function promoteSource(id, name) {
showConfirm(
"Zur Grundquelle machen",
@@ -595,7 +527,71 @@ function promoteSource(id, name) {
async () => {
try {
await API.post("/api/sources/tenant/" + id + "/promote");
loadTenantSources();
tenantSelected.delete(id);
loadUnifiedSources();
} catch (err) {
showToast(err.message, "error");
}
}
);
}
// --- Health-Details in der Ausklapp-Zeile ---
async function toggleHealthDetail(id) {
const row = document.getElementById("detail-" + id);
if (!row) return;
const isVisible = row.style.display !== "none";
toggleSourceInfo(id);
if (isVisible) return; // wurde gerade zugeklappt
const box = document.getElementById("health-detail-" + id);
if (!box) return;
if (sourceHealthDetailCache.has(id)) {
renderHealthDetail(id, sourceHealthDetailCache.get(id));
return;
}
box.innerHTML = '<div class="text-muted">Lade Health-Details…</div>';
try {
const res = await API.get("/api/sources/" + id + "/health");
sourceHealthDetailCache.set(id, res.checks || []);
renderHealthDetail(id, res.checks || []);
} catch (err) {
box.innerHTML = `<div class="text-danger">Health-Details konnten nicht geladen werden. ${esc(err.message || "")}</div>`;
}
}
function renderHealthDetail(id, checks) {
const box = document.getElementById("health-detail-" + id);
if (!box) return;
const s = globalSourcesCache.find((x) => x.id === id);
const deactivateBtn = s && s.status === "active"
? `<button class="btn btn-danger btn-small" onclick="deactivateSource(${id}, '${esc(s.name)}')">Quelle deaktivieren</button>`
: "";
if (!checks.length) {
box.innerHTML = `<div class="health-detail-head"><span class="text-muted">Keine Health-Checks für diese Quelle vorhanden.</span>${deactivateBtn}</div>`;
return;
}
const statusLabel = (st) => st === "error" ? "Fehler" : st === "warning" ? "Warnung" : "OK";
const rows = checks.map((c) => `
<div class="health-detail-row">
<span class="health-badge health-badge-${c.status === "error" ? "error" : c.status === "warning" ? "warning" : "ok"}">${statusLabel(c.status)}</span>
<span class="health-detail-type">${CHECK_TYPE_LABELS[c.check_type] || esc(c.check_type)}</span>
<span class="health-detail-msg">${esc(c.message || "")}</span>
<span class="text-secondary health-detail-ts">${formatDateTime(c.checked_at)}</span>
</div>`).join("");
box.innerHTML = `<div class="health-detail-head"><strong>Health-Checks</strong>${deactivateBtn}</div>${rows}`;
}
function deactivateSource(id, name) {
showConfirm(
"Quelle deaktivieren",
`Soll die Quelle "${name}" deaktiviert werden? Sie wird dann nicht mehr abgerufen.`,
async () => {
try {
await API.put("/api/sources/global/" + id, { status: "inactive" });
showToast("Quelle wurde deaktiviert.", "success");
sourceHealthDetailCache.delete(id);
loadUnifiedSources();
} catch (err) {
showToast(err.message, "error");
}
@@ -687,7 +683,7 @@ async function addDiscoveredFeeds() {
try {
const result = await API.post("/api/sources/discover/add", selected);
closeModal("modalDiscover");
loadGlobalSources();
loadUnifiedSources();
showToast(result.added + " Grundquelle(n) hinzugefügt" + (result.skipped ? ", " + result.skipped + " übersprungen" : ""), "success");
} catch (err) {
showToast("Fehler: " + err.message, "error");
@@ -874,7 +870,7 @@ async function triggerExternalReputationSync() {
}
function toggleSourceInfo(id) {
const row = document.getElementById("notes-" + id);
const row = document.getElementById("detail-" + id);
if (!row) return;
const isVisible = row.style.display !== "none";
row.style.display = isVisible ? "none" : "table-row";
@@ -941,7 +937,7 @@ function setupPdfUploadForm() {
if (typeof showToast === "function") {
showToast("PDF hochgeladen -- Verarbeitung laeuft im Hintergrund", "success");
}
loadGlobalSources();
loadUnifiedSources();
} catch (err) {
errEl.textContent = err.message || "Upload fehlgeschlagen";
errEl.style.display = "block";

Datei anzeigen

@@ -40,12 +40,13 @@ AUTH_PROTECTED = [
("DELETE", "/api/users/1"),
("GET", "/api/dashboard/stats"),
("GET", "/api/sources/meta"),
("GET", "/api/sources/global"),
("GET", "/api/sources"),
("POST", "/api/sources/global"),
("PUT", "/api/sources/global/1"),
("DELETE", "/api/sources/global/1"),
("GET", "/api/sources/global/stats"),
("GET", "/api/sources/tenant"),
("GET", "/api/sources/stats"),
("GET", "/api/sources/languages"),
("GET", "/api/sources/1/health"),
("POST", "/api/sources/tenant/1/promote"),
("POST", "/api/sources/tenant/bulk-promote"),
("POST", "/api/sources/discover"),