feat(sources): vereinte Quellenliste (Grund- + Kundenquellen in einer Tabelle)
- GET /api/sources mit scope=all|global|tenant ersetzt GET /global und
GET /tenant. Liefert org_name (LEFT JOIN organizations) und wie bisher
health_status + Artikel-Aktivitaet fuer ALLE Quellen.
- GET /global/stats wird GET /stats (zaehlt jetzt beide Herkuenfte,
neu by_origin und last_check), GET /global/languages wird GET /languages.
- PUT /global/{id} akzeptiert jetzt auch Kundenquellen (Guard ohne
tenant_id-Filter), damit der Deaktivieren-Knopf fuer beide funktioniert.
DELETE bleibt bewusst global-only.
- Neu GET /{source_id}/health fuer die Ausklapp-Details je Quelle.
- Frontend: EIN Render-/Filter-/Sortier-Pfad statt zwei (Tenant-Kette
komplett entfernt, ~200 Zeilen). Herkunfts-Filter, Organisations-Spalte,
Checkbox-Spalte + Bulk-Promote nur bei Herkunft Kundenquellen.
- Health-Badge ist klickbar und klappt eine gemeinsame Detailzeile aus
(Notizen + Checks mit Meldung und Zeitpunkt) mit Deaktivieren-Knopf.
Klickweg kaputte Quelle finden und deaktivieren: 3 statt 8 bis 10 Klicks.
- Unterreiter Kundenquellen entfaellt, erster Reiter heisst Quellenliste.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">ⓘ</span>`
|
? `<span class="src-info-toggle" onclick="toggleSourceInfo(${s.id})" title="Info einblenden">ⓘ</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";
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren