Promote develop → main (2026-07-25 22:22 UTC) #16
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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">ⓘ</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";
|
||||
|
||||
@@ -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"),
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren