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 alignments: Optional[list[str]] = None
@router.get("/global") @router.get("")
async def list_global_sources( async def list_sources(
scope: str = "all",
admin: dict = Depends(get_current_admin), admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency), 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 scope = all | global | tenant. Liefert pro Quelle den worst-case
(error > warning > ok > unknown). Damit kann das Frontend ein Inline-Badge Health-Status aus source_health_checks (error > warning > ok > unknown)
pro Zeile zeigen, ohne separate Health-Tab-Abfrage. 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 ( WITH article_stats AS (
-- Match per source-Name (case-insensitive). source_url im articles ist die -- 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. -- 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 GROUP BY source_id
) )
SELECT s.*, SELECT s.*,
o.name AS org_name,
CASE ha.rank CASE ha.rank
WHEN 3 THEN 'error' WHEN 3 THEN 'error'
WHEN 2 THEN 'warning' WHEN 2 THEN 'warning'
@@ -186,16 +196,13 @@ async def list_global_sources(
COALESCE(ast.a7d, 0) AS articles_7d, COALESCE(ast.a7d, 0) AS articles_7d,
COALESCE(ast.a30d, 0) AS articles_30d COALESCE(ast.a30d, 0) AS articles_30d
FROM sources s 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 article_stats ast ON ast.s_lower = LOWER(s.name)
LEFT JOIN health_agg ha ON ha.source_id = s.id 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 ORDER BY s.category, s.source_type, s.name
""") """)
rows = [dict(row) for row in await cursor.fetchall()] return [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
@router.post("/global", status_code=201) @router.post("/global", status_code=201)
@@ -245,13 +252,17 @@ async def update_global_source(
admin: dict = Depends(get_current_admin), admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency), 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( 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() row = await cursor.fetchone()
if not row: 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 = dict(row)
before_alignments = sorted((await _load_alignments_for(db, [source_id])).get(source_id, [])) 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") @router.get("/languages")
async def get_global_languages( async def get_languages(
admin: dict = Depends(get_current_admin), admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency), 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(""" cur = await db.execute("""
SELECT DISTINCT language SELECT DISTINCT language
FROM sources FROM sources
WHERE tenant_id IS NULL AND language IS NOT NULL AND language != '' WHERE language IS NOT NULL AND language != ''
ORDER BY language ORDER BY language
""") """)
return [r["language"] for r in await cur.fetchall()] return [r["language"] for r in await cur.fetchall()]
@router.get("/global/stats") @router.get("/stats")
async def get_global_stats( async def get_sources_stats(
admin: dict = Depends(get_current_admin), admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency), 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(""" cur = await db.execute("""
SELECT source_type, COUNT(*) AS count, COALESCE(SUM(article_count), 0) AS articles SELECT source_type, COUNT(*) AS count, COALESCE(SUM(article_count), 0) AS articles
FROM sources FROM sources
WHERE tenant_id IS NULL AND status = 'active' WHERE status = 'active'
GROUP BY source_type GROUP BY source_type
""") """)
by_type = {} by_type = {}
@@ -372,8 +387,18 @@ async def get_global_stats(
total += d["count"] total += d["count"]
total_articles += d["articles"] 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} health = {"errors": 0, "warnings": 0, "ok": 0}
last_check = None
cur = await db.execute(""" cur = await db.execute("""
SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks' 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 SELECT h.status AS hs, COUNT(DISTINCT h.source_id) AS cnt
FROM source_health_checks h FROM source_health_checks h
JOIN sources s ON s.id = h.source_id 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 GROUP BY h.status
""") """)
for r in await cur.fetchall(): for r in await cur.fetchall():
@@ -393,31 +418,21 @@ async def get_global_stats(
health["warnings"] = d["cnt"] health["warnings"] = d["cnt"]
elif d["hs"] == "ok": elif d["hs"] == "ok":
health["ok"] = d["cnt"] 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 { return {
"by_type": by_type, "by_type": by_type,
"by_origin": by_origin,
"total": total, "total": total,
"total_articles": total_articles, "total_articles": total_articles,
"health": health, "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") @router.post("/tenant/{source_id}/promote")
async def promote_to_global( async def promote_to_global(
source_id: int, source_id: int,
@@ -799,6 +814,36 @@ async def get_health_history(
return [dict(row) for row in await cursor.fetchall()] 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") @router.get("/suggestions")
async def get_suggestions( async def get_suggestions(
admin: dict = Depends(get_current_admin), 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-ok { background: rgba(16, 185, 129, 0.15); color: #10b981; }
.health-badge-unknown { background: rgba(148, 163, 184, 0.15); color: #94a3b8; } .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) === */ /* === Audit-Spur (Phase 5) === */
.modal.modal-large { .modal.modal-large {
max-width: 720px; max-width: 720px;

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=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> <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>
@@ -285,55 +285,62 @@
<!-- Sources Section --> <!-- Sources Section -->
<div class="section" id="sec-sources"> <div class="section" id="sec-sources">
<div class="nav-tabs" id="sourceSubTabs"> <div class="nav-tabs" id="sourceSubTabs">
<button class="nav-tab active" data-subtab="global-sources">Grundquellen</button> <button class="nav-tab active" data-subtab="global-sources">Quellenliste</button>
<button class="nav-tab" data-subtab="tenant-sources">Kundenquellen</button>
<button class="nav-tab" data-subtab="source-health">Quellen-Health</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="classification-review">Klassifikation <span class="sources-tab-badge" id="classificationPendingBadge">0</span></button>
<button class="nav-tab" data-subtab="x-scraper">X-Recherche-Konten</button> <button class="nav-tab" data-subtab="x-scraper">X-Recherche-Konten</button>
</div> </div>
<!-- Grundquellen --> <!-- Vereinte Quellenliste (Grund- + Kundenquellen) -->
<div class="section active" id="sub-global-sources"> <div class="section active" id="sub-global-sources">
<div class="sources-stats-bar" id="globalStatsBar"></div> <div class="sources-stats-bar" id="globalStatsBar"></div>
<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="Grundquelle suchen..."> <input type="text" class="search-input" id="globalSourceSearch" placeholder="Quelle suchen...">
<select class="filter-select" id="globalFilterType" onchange="filterGlobalSources()"> <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> <option value="">Alle Typen</option>
</select> </select>
<select class="filter-select" id="globalFilterCategory" onchange="filterGlobalSources()"> <select class="filter-select" id="globalFilterCategory" onchange="filterUnifiedSources()">
<option value="">Alle Kategorien</option> <option value="">Alle Kategorien</option>
</select> </select>
<select class="filter-select" id="globalFilterStatus" onchange="filterGlobalSources()"> <select class="filter-select" id="globalFilterStatus" onchange="filterUnifiedSources()">
<option value="">Alle Status</option> <option value="">Alle Status</option>
<option value="active">Aktiv</option> <option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option> <option value="inactive">Inaktiv</option>
</select> </select>
<select class="filter-select" id="globalFilterLanguage" onchange="filterGlobalSources()"> <select class="filter-select" id="globalFilterLanguage" onchange="filterUnifiedSources()">
<option value="">Alle Sprachen</option> <option value="">Alle Sprachen</option>
</select> </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-secondary" id="discoverSourceBtn">Erkennen</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-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="card"> <div class="card">
<div class="table-wrap"> <div class="table-wrap">
<table> <table id="unifiedTable">
<thead> <thead>
<tr> <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>URL</th>
<th class="sortable" data-sort="domain" onclick="sortGlobalSources('domain')">Domain <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="sortGlobalSources('source_type')">Typ <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="sortGlobalSources('article_count')">Artikel <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="sortGlobalSources('articles_30d')">Aktivität <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="sortGlobalSources('language')">Sprache <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>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="last_seen_at" onclick="sortUnifiedSources('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="health_status" onclick="sortUnifiedSources('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="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> <th>Aktionen</th>
</tr> </tr>
</thead> </thead>
@@ -343,52 +350,6 @@
</div> </div>
</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; <!-- Quellen-Health (Sub-Tab) - drei Bereiche als Sub-Sub-Tabs;
source-health.js rendert pro Bereich in den jeweiligen Container. --> source-health.js rendert pro Bereich in den jeweiligen Container. -->
<div class="section" id="sub-source-health"> <div class="section" id="sub-source-health">
@@ -1055,9 +1016,9 @@
<script src="/static/js/a11y.js?v=20260725a"></script> <script src="/static/js/a11y.js?v=20260725a"></script>
<script src="/static/js/app.js?v=20260725r"></script> <script src="/static/js/app.js?v=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/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> <script src="/static/js/audit.js?v=20260509d"></script>
<div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div> <div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div>
</body> </body>

Datei anzeigen

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

Datei anzeigen

@@ -1,17 +1,26 @@
/* Grundquellen & Kundenquellen Management */ /* Vereinte Quellenliste (Grund- + Kundenquellen) */
"use strict"; "use strict";
// Hält Grund- UND Kundenquellen (GET /api/sources?scope=all)
let globalSourcesCache = []; let globalSourcesCache = [];
let tenantSourcesCache = []; // Auswahl für Bulk-Promote (nur bei Herkunfts-Filter Kundenquellen sichtbar)
// Phase 3c: Tenant-Tab State
let tenantFilters = { search: "", type: "", category: "", org: "", language: "" };
let tenantSort = { field: "org_name", asc: true };
let tenantSelected = new Set(); 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 editingSourceId = null;
let globalSortField = "category"; let globalSortField = "category";
let globalSortAsc = true; 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) // CATEGORY_LABELS jetzt global (aus app.js loadMeta)
// TYPE_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 // Beim Tab-Wechsel auf "Quellen" laden
document.querySelectorAll('.nav-tab[data-section="sources"]').forEach((tab) => { 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.querySelectorAll("#sec-sources > .section").forEach((s) => s.classList.remove("active"));
document.getElementById("sub-" + subtab).classList.add("active"); document.getElementById("sub-" + subtab).classList.add("active");
if (subtab === "global-sources") loadGlobalSources(); if (subtab === "global-sources") loadUnifiedSources();
else if (subtab === "tenant-sources") loadTenantSources();
else if (subtab === "source-health") loadHealthData(); else if (subtab === "source-health") loadHealthData();
else if (subtab === "classification-review") loadClassificationQueue(); else if (subtab === "classification-review") loadClassificationQueue();
else if (subtab === "x-scraper") loadXScraperAccounts(); else if (subtab === "x-scraper") loadXScraperAccounts();
@@ -71,8 +79,8 @@ function setupSourceSubTabs() {
}); });
} }
// --- Grundquellen --- // --- Vereinte Quellenliste ---
async function loadGlobalSources() { async function loadUnifiedSources() {
try { try {
await ensureMeta(); await ensureMeta();
// Kategorien/Typen-Dropdowns aus META befüllen (idempotent) // Kategorien/Typen-Dropdowns aus META befüllen (idempotent)
@@ -81,21 +89,17 @@ async function loadGlobalSources() {
populateSelect(document.getElementById("globalFilterType"), window.META.types || [], "Alle Typen"); 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/global"), API.get("/api/sources?scope=all"),
API.get("/api/sources/global/stats"), API.get("/api/sources/stats"),
API.get("/api/sources/global/languages").catch(() => []), API.get("/api/sources/languages").catch(() => []),
]); ]);
globalSourcesCache = list; globalSourcesCache = list;
sourceHealthDetailCache.clear();
populateSelect( populateSelect(
document.getElementById("globalFilterLanguage"), document.getElementById("globalFilterLanguage"),
(languages || []).map(l => ({ key: l, label: l })), (languages || []).map(l => ({ key: l, label: l })),
"Alle Sprachen", "Alle Sprachen",
); );
populateSelect(
document.getElementById("tenantFilterLanguage"),
(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) {
@@ -106,10 +110,10 @@ async function loadGlobalSources() {
dl.appendChild(o); dl.appendChild(o);
}); });
} }
renderGlobalStats(stats); renderUnifiedStats(stats);
renderGlobalSources(globalSourcesCache); filterUnifiedSources();
} catch (err) { } catch (err) {
console.error("Grundquellen laden fehlgeschlagen:", err); console.error("Quellenliste laden fehlgeschlagen:", err);
} }
} }
@@ -164,7 +168,7 @@ function formatDateTime(iso) {
} catch { return iso; } } catch { return iso; }
} }
function renderGlobalStats(stats) { function renderUnifiedStats(stats) {
const bar = document.getElementById("globalStatsBar"); const bar = document.getElementById("globalStatsBar");
if (!bar) return; if (!bar) return;
if (!stats || !stats.by_type) { bar.innerHTML = ""; 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 types = window.META && window.META.types ? window.META.types : [];
const parts = []; const parts = [];
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${stats.total || 0}</span> Quellen gesamt</span>`); 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) { for (const t of types) {
const v = stats.by_type[t.key] || { count: 0, articles: 0 }; 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>`); 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(""); bar.innerHTML = parts.join("");
} }
function renderGlobalSources(sources) { function renderUnifiedSources(sources) {
const tbody = document.getElementById("globalSourceTable"); const tbody = document.getElementById("globalSourceTable");
const cols = 12; const cols = 14;
if (sources.length === 0) { 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; return;
} }
@@ -209,18 +219,36 @@ function renderGlobalSources(sources) {
const count = grouped[cat].length; 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>`; 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) => { grouped[cat].forEach((s) => {
const isTenant = s.tenant_id != null;
const hasNotes = s.notes && s.notes.trim(); const hasNotes = s.notes && s.notes.trim();
const infoBtn = hasNotes const infoBtn = hasNotes
? `<span class="src-info-toggle" onclick="toggleSourceInfo(${s.id})" title="Info einblenden">&#9432;</span>` ? `<span class="src-info-toggle" onclick="toggleSourceInfo(${s.id})" title="Info einblenden">&#9432;</span>`
: ''; : '';
const notesRow = hasNotes // EINE gemeinsame Detailzeile je Quelle: Notizen + Health-Details.
? `<tr class="src-notes-row" id="notes-${s.id}" style="display:none;"><td colspan="${cols}" class="src-notes-cell">${esc(s.notes)}</td></tr>` 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 lastSeen = s.last_seen_at ? formatDate(s.last_seen_at) : "-";
const hs = s.health_status || "unknown"; const hs = s.health_status || "unknown";
const hsLabel = { error: "Fehler", warning: "Warnung", ok: "OK", unknown: "—" }[hs]; const hsLabel = { error: "Fehler", warning: "Warnung", ok: "OK", unknown: "—" }[hs];
const hsClass = "health-badge-" + (hs === "unknown" ? "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> html += `<tr>
${selectCell}
<td>${infoBtn} ${esc(s.name)}</td> <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 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> <td>${esc(s.domain || "-")}</td>
@@ -230,23 +258,22 @@ function renderGlobalSources(sources) {
<td class="text-secondary">${esc(s.language || "-")}</td> <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" 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 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><span class="badge badge-${s.status === "active" ? "active" : "inactive"}">${s.status === "active" ? "Aktiv" : "Inaktiv"}</span></td>
<td> <td class="text-secondary">${isTenant ? esc(s.org_name || ("Org " + s.tenant_id)) : '<span class="text-muted">—</span>'}</td>
<button class="btn btn-secondary btn-small" onclick="editGlobalSource(${s.id})">Bearbeiten</button> <td>${actions}</td>
<button class="btn btn-secondary btn-small" onclick="showSourceAudit(${s.id}, '${esc(s.name)}')">Audit</button> </tr>${detailRow}`;
<button class="btn btn-danger btn-small" onclick="confirmDeleteGlobalSource(${s.id}, '${esc(s.name)}')">Löschen</button>
</td>
</tr>${notesRow}`;
}); });
}); });
tbody.innerHTML = html; 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 // Sort-Icons aktualisieren
document.querySelectorAll("th.sortable .sort-icon").forEach(el => el.textContent = ""); document.querySelectorAll("#unifiedTable th.sortable .sort-icon").forEach(el => el.textContent = "");
const activeHeader = document.querySelector(`th.sortable[data-sort="${globalSortField}"] .sort-icon`); const activeHeader = document.querySelector(`#unifiedTable th.sortable[data-sort="${globalSortField}"] .sort-icon`);
if (activeHeader) activeHeader.textContent = globalSortAsc ? " ▲" : " ▼"; if (activeHeader) activeHeader.textContent = globalSortAsc ? " ▲" : " ▼";
} }
@@ -254,19 +281,28 @@ function renderGlobalSources(sources) {
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const el = document.getElementById("globalSourceSearch"); const el = document.getElementById("globalSourceSearch");
if (el) { if (el) {
el.addEventListener("input", () => filterGlobalSources()); el.addEventListener("input", () => filterUnifiedSources());
} }
}); });
function filterGlobalSources() { function filterUnifiedSources() {
const q = (document.getElementById("globalSourceSearch")?.value || "").toLowerCase(); const q = (document.getElementById("globalSourceSearch")?.value || "").toLowerCase();
const typeFilter = document.getElementById("globalFilterType")?.value || ""; const typeFilter = document.getElementById("globalFilterType")?.value || "";
const catFilter = document.getElementById("globalFilterCategory")?.value || ""; const catFilter = document.getElementById("globalFilterCategory")?.value || "";
const statusFilter = document.getElementById("globalFilterStatus")?.value || ""; const statusFilter = document.getElementById("globalFilterStatus")?.value || "";
const langFilter = document.getElementById("globalFilterLanguage")?.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) => { 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 (typeFilter && s.source_type !== typeFilter) return false;
if (catFilter && s.category !== catFilter) return false; if (catFilter && s.category !== catFilter) return false;
if (statusFilter && s.status !== statusFilter) return false; if (statusFilter && s.status !== statusFilter) return false;
@@ -290,17 +326,26 @@ function filterGlobalSources() {
return globalSortAsc ? cmp : -cmp; return globalSortAsc ? cmp : -cmp;
}); });
renderGlobalSources(filtered); renderUnifiedSources(filtered);
} }
function sortGlobalSources(field) { function sortUnifiedSources(field) {
if (globalSortField === field) { if (globalSortField === field) {
globalSortAsc = !globalSortAsc; globalSortAsc = !globalSortAsc;
} else { } else {
globalSortField = field; globalSortField = field;
globalSortAsc = true; 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 --- // --- Grundquelle erstellen/bearbeiten ---
@@ -392,7 +437,7 @@ function setupSourceForms() {
await API.post("/api/sources/global", body); await API.post("/api/sources/global", body);
} }
closeModal("modalSource"); closeModal("modalSource");
loadGlobalSources(); loadUnifiedSources();
} catch (err) { } catch (err) {
errEl.textContent = err.message; errEl.textContent = err.message;
errEl.style.display = "block"; errEl.style.display = "block";
@@ -417,7 +462,7 @@ function confirmDeleteGlobalSource(id, name) {
async () => { async () => {
try { try {
await API.del("/api/sources/global/" + id); await API.del("/api/sources/global/" + id);
loadGlobalSources(); loadUnifiedSources();
} catch (err) { } catch (err) {
showToast(err.message, "error"); showToast(err.message, "error");
} }
@@ -425,84 +470,9 @@ function confirmDeleteGlobalSource(id, name) {
); );
} }
// --- Kundenquellen --- // --- Bulk-Promote (Kundenquellen in der vereinten Liste) ---
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();
}
function toggleTenantSelectAll(checked) { function toggleTenantSelectAll(checked) {
document.querySelectorAll("#tenantSourceTable input.tenant-select").forEach(cb => { document.querySelectorAll("#globalSourceTable input.tenant-select").forEach(cb => {
cb.checked = checked; cb.checked = checked;
const id = parseInt(cb.dataset.id); const id = parseInt(cb.dataset.id);
if (checked) tenantSelected.add(id); else tenantSelected.delete(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); if (checked) tenantSelected.add(id); else tenantSelected.delete(id);
updateBulkButton(); updateBulkButton();
// Header-Checkbox anpassen // Header-Checkbox anpassen
const visible = document.querySelectorAll("#tenantSourceTable input.tenant-select").length; const visible = document.querySelectorAll("#globalSourceTable input.tenant-select").length;
const checkedVisible = document.querySelectorAll("#tenantSourceTable input.tenant-select:checked").length; const checkedVisible = document.querySelectorAll("#globalSourceTable input.tenant-select:checked").length;
const all = document.getElementById("tenantSelectAll"); const all = document.getElementById("unifiedSelectAll");
if (all) all.checked = visible > 0 && visible === checkedVisible; 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`; if (result.failed && result.failed.length) msg += `, ${result.failed.length} Fehler`;
showToast(msg, result.failed && result.failed.length ? "warning" : "success"); showToast(msg, result.failed && result.failed.length ? "warning" : "success");
tenantSelected.clear(); tenantSelected.clear();
await loadTenantSources(); await loadUnifiedSources();
} catch (err) { } catch (err) {
showToast("Bulk-Promote fehlgeschlagen: " + err.message, "error"); 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) { function promoteSource(id, name) {
showConfirm( showConfirm(
"Zur Grundquelle machen", "Zur Grundquelle machen",
@@ -595,7 +527,71 @@ function promoteSource(id, name) {
async () => { async () => {
try { try {
await API.post("/api/sources/tenant/" + id + "/promote"); 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) { } catch (err) {
showToast(err.message, "error"); showToast(err.message, "error");
} }
@@ -687,7 +683,7 @@ async function addDiscoveredFeeds() {
try { try {
const result = await API.post("/api/sources/discover/add", selected); const result = await API.post("/api/sources/discover/add", selected);
closeModal("modalDiscover"); closeModal("modalDiscover");
loadGlobalSources(); loadUnifiedSources();
showToast(result.added + " Grundquelle(n) hinzugefügt" + (result.skipped ? ", " + result.skipped + " übersprungen" : ""), "success"); showToast(result.added + " Grundquelle(n) hinzugefügt" + (result.skipped ? ", " + result.skipped + " übersprungen" : ""), "success");
} catch (err) { } catch (err) {
showToast("Fehler: " + err.message, "error"); showToast("Fehler: " + err.message, "error");
@@ -874,7 +870,7 @@ async function triggerExternalReputationSync() {
} }
function toggleSourceInfo(id) { function toggleSourceInfo(id) {
const row = document.getElementById("notes-" + id); const row = document.getElementById("detail-" + id);
if (!row) return; if (!row) return;
const isVisible = row.style.display !== "none"; const isVisible = row.style.display !== "none";
row.style.display = isVisible ? "none" : "table-row"; row.style.display = isVisible ? "none" : "table-row";
@@ -941,7 +937,7 @@ function setupPdfUploadForm() {
if (typeof showToast === "function") { if (typeof showToast === "function") {
showToast("PDF hochgeladen -- Verarbeitung laeuft im Hintergrund", "success"); showToast("PDF hochgeladen -- Verarbeitung laeuft im Hintergrund", "success");
} }
loadGlobalSources(); loadUnifiedSources();
} catch (err) { } catch (err) {
errEl.textContent = err.message || "Upload fehlgeschlagen"; errEl.textContent = err.message || "Upload fehlgeschlagen";
errEl.style.display = "block"; errEl.style.display = "block";

Datei anzeigen

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