Promote develop → main (2026-07-25 22:22 UTC) #16
@@ -70,6 +70,10 @@ async def list_audit(
|
|||||||
where.append("ts >= ?")
|
where.append("ts >= ?")
|
||||||
params.append(from_ts)
|
params.append(from_ts)
|
||||||
if to_ts:
|
if to_ts:
|
||||||
|
# Datums-Only-Wert (aus <input type=date>) soll den gewählten Tag
|
||||||
|
# komplett EINschließen, sonst fiele er wegen ts <= 'YYYY-MM-DD' raus.
|
||||||
|
if len(to_ts) == 10:
|
||||||
|
to_ts = to_ts + " 23:59:59"
|
||||||
where.append("ts <= ?")
|
where.append("ts <= ?")
|
||||||
params.append(to_ts)
|
params.append(to_ts)
|
||||||
|
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ async def tasks_summary(
|
|||||||
):
|
):
|
||||||
"""Billiger Zähler für den Aufgaben-Badge im Quellen-Reiter.
|
"""Billiger Zähler für den Aufgaben-Badge im Quellen-Reiter.
|
||||||
|
|
||||||
classification_pending nutzt DIESELBE Bedingung wie /classification/stats,
|
classification_pending nutzt DIESELBE Bedingung wie /classification/queue,
|
||||||
damit Badge und Review-Karten nie auseinanderlaufen.
|
damit Badge und Review-Karten nie auseinanderlaufen.
|
||||||
"""
|
"""
|
||||||
suggestions_pending = 0
|
suggestions_pending = 0
|
||||||
@@ -403,13 +403,13 @@ async def get_sources_stats(
|
|||||||
):
|
):
|
||||||
"""Aggregierte Stats für die Stats-Bar der vereinten Quellenliste.
|
"""Aggregierte Stats für die Stats-Bar der vereinten Quellenliste.
|
||||||
|
|
||||||
Zählt Grund- UND Kundenquellen. by_origin liefert die Aufteilung,
|
Zählt Grund- UND Kundenquellen, aktive wie inaktive (die Liste zeigt
|
||||||
last_check den Zeitpunkt des jüngsten Health-Checks.
|
ebenfalls alle). 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 status = 'active'
|
|
||||||
GROUP BY source_type
|
GROUP BY source_type
|
||||||
""")
|
""")
|
||||||
by_type = {}
|
by_type = {}
|
||||||
@@ -425,7 +425,6 @@ async def get_sources_stats(
|
|||||||
SELECT CASE WHEN tenant_id IS NULL THEN 'global' ELSE 'tenant' END AS origin,
|
SELECT CASE WHEN tenant_id IS NULL THEN 'global' ELSE 'tenant' END AS origin,
|
||||||
COUNT(*) AS cnt
|
COUNT(*) AS cnt
|
||||||
FROM sources
|
FROM sources
|
||||||
WHERE status = 'active'
|
|
||||||
GROUP BY origin
|
GROUP BY origin
|
||||||
""")
|
""")
|
||||||
by_origin = {dict(r)["origin"]: dict(r)["cnt"] for r in await cur.fetchall()}
|
by_origin = {dict(r)["origin"]: dict(r)["cnt"] for r in await cur.fetchall()}
|
||||||
@@ -441,7 +440,6 @@ async def get_sources_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.status = 'active'
|
|
||||||
GROUP BY h.status
|
GROUP BY h.status
|
||||||
""")
|
""")
|
||||||
for r in await cur.fetchall():
|
for r in await cur.fetchall():
|
||||||
@@ -716,107 +714,6 @@ async def add_discovered_sources(
|
|||||||
|
|
||||||
# --- Health-Check & Vorschläge ---
|
# --- Health-Check & Vorschläge ---
|
||||||
|
|
||||||
@router.get("/health")
|
|
||||||
async def get_health(
|
|
||||||
limit: int = 100,
|
|
||||||
offset: int = 0,
|
|
||||||
admin: dict = Depends(get_current_admin),
|
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
|
||||||
):
|
|
||||||
"""Health-Check-Ergebnisse abrufen.
|
|
||||||
|
|
||||||
Default-Limit 100, sortiert nach Status (errors first, dann warnings, dann ok).
|
|
||||||
Counters (errors/warnings/ok/total_checks) beziehen sich auf den GESAMTEN
|
|
||||||
Datenbestand, nicht nur auf die zurückgegebene Page. Damit kann das Frontend
|
|
||||||
den vollen Status anzeigen, ohne alle Zeilen rendern zu müssen.
|
|
||||||
has_more zeigt an, ob es weitere Items zum Nachladen gibt.
|
|
||||||
all_orgs liefert die Liste aller Tenants mit Health-Checks (für Filter-Dropdown).
|
|
||||||
"""
|
|
||||||
limit = max(1, min(int(limit or 100), 5000))
|
|
||||||
offset = max(0, int(offset or 0))
|
|
||||||
|
|
||||||
# Prüfen ob Tabelle existiert
|
|
||||||
cursor = await db.execute(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks'"
|
|
||||||
)
|
|
||||||
if not await cursor.fetchone():
|
|
||||||
return {
|
|
||||||
"last_check": None, "total_checks": 0,
|
|
||||||
"errors": 0, "warnings": 0, "ok": 0,
|
|
||||||
"checks": [], "all_orgs": [],
|
|
||||||
"limit": limit, "offset": offset, "has_more": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Aggregate über GESAMTEN Bestand. Eine GROUP-BY-Query nach (check_type, status)
|
|
||||||
# liefert sowohl die Top-Counters als auch das feine Breakdown für die UI.
|
|
||||||
cursor = await db.execute(
|
|
||||||
"SELECT check_type, status, COUNT(*) AS n FROM source_health_checks GROUP BY check_type, status"
|
|
||||||
)
|
|
||||||
breakdown = {} # {check_type: {status: count}}
|
|
||||||
error_count = 0
|
|
||||||
warning_count = 0
|
|
||||||
ok_count = 0
|
|
||||||
for row in await cursor.fetchall():
|
|
||||||
ct = row["check_type"]
|
|
||||||
st = row["status"]
|
|
||||||
breakdown.setdefault(ct, {})[st] = row["n"]
|
|
||||||
if st == "error":
|
|
||||||
error_count += row["n"]
|
|
||||||
elif st == "warning":
|
|
||||||
warning_count += row["n"]
|
|
||||||
elif st == "ok":
|
|
||||||
ok_count += row["n"]
|
|
||||||
total_checks = error_count + warning_count + ok_count
|
|
||||||
|
|
||||||
# Paginierte Daten
|
|
||||||
cursor = await db.execute("""
|
|
||||||
SELECT
|
|
||||||
h.source_id, s.name, s.domain, s.tenant_id, s.language,
|
|
||||||
o.name AS org_name,
|
|
||||||
h.check_type, h.status, h.message
|
|
||||||
FROM source_health_checks h
|
|
||||||
JOIN sources s ON s.id = h.source_id
|
|
||||||
LEFT JOIN organizations o ON o.id = s.tenant_id
|
|
||||||
ORDER BY
|
|
||||||
CASE h.status WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
|
|
||||||
s.name
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
""", (limit, offset))
|
|
||||||
checks = [dict(row) for row in await cursor.fetchall()]
|
|
||||||
|
|
||||||
# Org-Liste (alle Tenants mit Health-Checks, für Frontend-Filter-Dropdown)
|
|
||||||
cursor = await db.execute("""
|
|
||||||
SELECT DISTINCT s.tenant_id AS id, o.name AS name
|
|
||||||
FROM source_health_checks h
|
|
||||||
JOIN sources s ON s.id = h.source_id
|
|
||||||
LEFT JOIN organizations o ON o.id = s.tenant_id
|
|
||||||
WHERE s.tenant_id IS NOT NULL
|
|
||||||
ORDER BY o.name
|
|
||||||
""")
|
|
||||||
all_orgs = [dict(row) for row in await cursor.fetchall()]
|
|
||||||
|
|
||||||
cursor = await db.execute("SELECT MAX(checked_at) as last_check FROM source_health_checks")
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
last_check = row["last_check"] if row else None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"last_check": last_check,
|
|
||||||
"total_checks": total_checks,
|
|
||||||
"errors": error_count,
|
|
||||||
"warnings": warning_count,
|
|
||||||
"ok": ok_count,
|
|
||||||
"breakdown": breakdown,
|
|
||||||
"checks": checks,
|
|
||||||
"all_orgs": all_orgs,
|
|
||||||
"limit": limit,
|
|
||||||
"offset": offset,
|
|
||||||
"has_more": (offset + len(checks)) < total_checks,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health/history")
|
@router.get("/health/history")
|
||||||
async def get_health_history(
|
async def get_health_history(
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
@@ -929,7 +826,11 @@ async def get_suggestions(
|
|||||||
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 Vorschläge abrufen (pending zuerst, dann letzte 20 bearbeitete)."""
|
"""Alle OFFENEN Vorschläge plus die letzten 50 bearbeiteten.
|
||||||
|
|
||||||
|
Kein Deckel auf pending, sonst liefe der Aufgaben-Badge
|
||||||
|
(tasks/summary zählt ungedeckelt) der Tabelle davon.
|
||||||
|
"""
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='source_suggestions'"
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='source_suggestions'"
|
||||||
)
|
)
|
||||||
@@ -938,12 +839,17 @@ async def get_suggestions(
|
|||||||
|
|
||||||
cursor = await db.execute("""
|
cursor = await db.execute("""
|
||||||
SELECT * FROM source_suggestions
|
SELECT * FROM source_suggestions
|
||||||
ORDER BY
|
WHERE status = 'pending'
|
||||||
CASE status WHEN 'pending' THEN 0 ELSE 1 END,
|
ORDER BY created_at DESC
|
||||||
created_at DESC
|
""")
|
||||||
|
pending = [dict(row) for row in await cursor.fetchall()]
|
||||||
|
cursor = await db.execute("""
|
||||||
|
SELECT * FROM source_suggestions
|
||||||
|
WHERE status != 'pending'
|
||||||
|
ORDER BY created_at DESC
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
""")
|
""")
|
||||||
return [dict(row) for row in await cursor.fetchall()]
|
return pending + [dict(row) for row in await cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
class SuggestionAction(BaseModel):
|
class SuggestionAction(BaseModel):
|
||||||
@@ -1172,31 +1078,6 @@ Nur das JSON, kein anderer Text."""
|
|||||||
# === Klassifikations-Review (LLM-Vorschlaege approve/reject/reclassify) ===
|
# === Klassifikations-Review (LLM-Vorschlaege approve/reject/reclassify) ===
|
||||||
|
|
||||||
|
|
||||||
@router.get("/classification/stats")
|
|
||||||
async def classification_stats(
|
|
||||||
admin: dict = Depends(get_current_admin),
|
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
|
||||||
):
|
|
||||||
"""Counts pro classification_source-Wert + Anzahl Pending-Reviews (alle Quellen)."""
|
|
||||||
cursor = await db.execute(
|
|
||||||
"""SELECT classification_source, COUNT(*) as cnt
|
|
||||||
FROM sources
|
|
||||||
WHERE status = 'active'
|
|
||||||
GROUP BY classification_source"""
|
|
||||||
)
|
|
||||||
by_source = {row["classification_source"] or "legacy": row["cnt"] for row in await cursor.fetchall()}
|
|
||||||
cursor = await db.execute(
|
|
||||||
"""SELECT COUNT(*) as cnt FROM sources
|
|
||||||
WHERE status = 'active' AND proposed_political_orientation IS NOT NULL"""
|
|
||||||
)
|
|
||||||
pending = (await cursor.fetchone())["cnt"]
|
|
||||||
return {
|
|
||||||
"by_classification_source": by_source,
|
|
||||||
"pending_review": pending,
|
|
||||||
"total": sum(by_source.values()),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/classification/queue")
|
@router.get("/classification/queue")
|
||||||
async def classification_queue(
|
async def classification_queue(
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
|
|||||||
@@ -586,36 +586,6 @@ tr:hover td {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Recent activity --- */
|
|
||||||
.activity-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 8px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-icon {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-icon.org {
|
|
||||||
background: rgba(59, 130, 246, 0.2);
|
|
||||||
color: #93c5fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-icon.user {
|
|
||||||
background: rgba(34, 197, 94, 0.2);
|
|
||||||
color: #86efac;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Org detail panel --- */
|
/* --- Org detail panel --- */
|
||||||
.detail-panel {
|
.detail-panel {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -702,21 +672,6 @@ tr:hover td {
|
|||||||
|
|
||||||
|
|
||||||
/* --- Health & Suggestion Badges --- */
|
/* --- Health & Suggestion Badges --- */
|
||||||
.badge-health-error {
|
|
||||||
background: rgba(239, 68, 68, 0.2);
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-health-warning {
|
|
||||||
background: rgba(245, 158, 11, 0.2);
|
|
||||||
color: #fcd34d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-health-ok {
|
|
||||||
background: rgba(34, 197, 94, 0.2);
|
|
||||||
color: #86efac;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-suggestion-add_source {
|
.badge-suggestion-add_source {
|
||||||
background: rgba(59, 130, 246, 0.2);
|
background: rgba(59, 130, 246, 0.2);
|
||||||
color: #93c5fd;
|
color: #93c5fd;
|
||||||
@@ -1133,13 +1088,6 @@ table.show-select .select-col { display: table-cell; }
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
.review-conf-filter {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.review-toolbar-actions { display: flex; gap: 6px; }
|
.review-toolbar-actions { display: flex; gap: 6px; }
|
||||||
|
|
||||||
.review-list { display: flex; flex-direction: column; gap: 8px; }
|
.review-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
@@ -1440,12 +1388,6 @@ details.adv-section .adv-hint { grid-column: 1 / -1; font-size: 12px; color: var
|
|||||||
[data-theme="light"] .badge-expired { color: #B91C1C; }
|
[data-theme="light"] .badge-expired { color: #B91C1C; }
|
||||||
[data-theme="light"] .badge-revoked { color: #475569; }
|
[data-theme="light"] .badge-revoked { color: #475569; }
|
||||||
[data-theme="light"] .badge-none { color: #475569; }
|
[data-theme="light"] .badge-none { color: #475569; }
|
||||||
[data-theme="light"] .activity-icon.org { color: #1D4ED8; }
|
|
||||||
[data-theme="light"] .activity-icon.user { color: #047857; }
|
|
||||||
[data-theme="light"] /* --- Health & Suggestion Badges --- */
|
|
||||||
.badge-health-error { color: #B91C1C; }
|
|
||||||
[data-theme="light"] .badge-health-warning { color: #B45309; }
|
|
||||||
[data-theme="light"] .badge-health-ok { color: #047857; }
|
|
||||||
[data-theme="light"] .badge-suggestion-add_source { color: #1D4ED8; }
|
[data-theme="light"] .badge-suggestion-add_source { color: #1D4ED8; }
|
||||||
[data-theme="light"] .badge-suggestion-deactivate_source { color: #B45309; }
|
[data-theme="light"] .badge-suggestion-deactivate_source { color: #B45309; }
|
||||||
[data-theme="light"] .badge-suggestion-remove_source { color: #B91C1C; }
|
[data-theme="light"] .badge-suggestion-remove_source { color: #B91C1C; }
|
||||||
|
|||||||
@@ -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=20260725l">
|
<link rel="stylesheet" href="/static/css/style.css?v=20260725m">
|
||||||
<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>
|
||||||
@@ -341,7 +341,7 @@
|
|||||||
<div class="review-toolbar-actions">
|
<div class="review-toolbar-actions">
|
||||||
<button class="btn btn-secondary btn-small" onclick="triggerExternalReputationSync()" title="IFCN-Faktenchecker und EUvsDisinfo neu syncen">Externe Daten syncen</button>
|
<button class="btn btn-secondary btn-small" onclick="triggerExternalReputationSync()" title="IFCN-Faktenchecker und EUvsDisinfo neu syncen">Externe Daten syncen</button>
|
||||||
<button class="btn btn-secondary btn-small" onclick="triggerBulkClassify()" title="LLM-Klassifikation für noch unklassifizierte Quellen starten">+ Klassifikation starten</button>
|
<button class="btn btn-secondary btn-small" onclick="triggerBulkClassify()" title="LLM-Klassifikation für noch unklassifizierte Quellen starten">+ Klassifikation starten</button>
|
||||||
<button class="btn btn-primary btn-small" onclick="bulkApproveHighConfidence()" title="Alle Vorschläge ab 0.85 Konfidenz übernehmen">Alle ≥ 0.85 genehmigen</button>
|
<button class="btn btn-primary btn-small" id="bulkApproveBtn" onclick="bulkApproveHighConfidence()" disabled title="Erst aktiv, wenn Klassifikations-Vorschläge ausstehen">Alle ≥ 0.85 genehmigen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-bar" id="aufgabenFilterBar"></div>
|
<div class="filter-bar" id="aufgabenFilterBar"></div>
|
||||||
@@ -896,7 +896,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-secondary" id="confirmCancelBtn" onclick="closeModal('modalConfirm')">Abbrechen</button>
|
<button class="btn btn-secondary" id="confirmCancelBtn" onclick="closeModal('modalConfirm')">Abbrechen</button>
|
||||||
<button class="btn btn-danger" id="confirmOkBtn">Bestätigen</button>
|
<button class="btn btn-primary" id="confirmOkBtn">Bestätigen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -970,10 +970,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/a11y.js?v=20260725a"></script>
|
<script src="/static/js/a11y.js?v=20260725a"></script>
|
||||||
<script src="/static/js/app.js?v=20260725u"></script>
|
<script src="/static/js/app.js?v=20260725v"></script>
|
||||||
<script src="/static/js/sources.js?v=20260725e"></script>
|
<script src="/static/js/sources.js?v=20260725f"></script>
|
||||||
<script src="/static/js/aufgaben.js?v=20260725b"></script>
|
<script src="/static/js/aufgaben.js?v=20260725c"></script>
|
||||||
<script src="/static/js/x-scraper.js?v=20260522a"></script>
|
<script src="/static/js/x-scraper.js?v=20260725a"></script>
|
||||||
<script src="/static/js/audit.js?v=20260725a"></script>
|
<script src="/static/js/audit.js?v=20260725a"></script>
|
||||||
<div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div>
|
<div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -437,7 +437,8 @@ function confirmDeleteOrg() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message, "error");
|
showToast(err.message, "error");
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
{ danger: true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,7 +534,8 @@ function confirmDeleteUser(userId, email) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message, "error");
|
showToast(err.message, "error");
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
{ danger: true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -834,9 +836,19 @@ let confirmCallback = null;
|
|||||||
|
|
||||||
let confirmResolver = null;
|
let confirmResolver = null;
|
||||||
|
|
||||||
function showConfirm(title, text, callback) {
|
function showConfirm(title, text, callback, opts) {
|
||||||
|
// Promise-Stil erlaubt opts als dritten Parameter: showConfirm(t, x, {danger:true})
|
||||||
|
if (callback && typeof callback === "object") { opts = callback; callback = null; }
|
||||||
|
opts = opts || {};
|
||||||
document.getElementById("confirmTitle").textContent = title;
|
document.getElementById("confirmTitle").textContent = title;
|
||||||
document.getElementById("confirmText").textContent = text;
|
document.getElementById("confirmText").textContent = text;
|
||||||
|
// Bestätigen-Knopf ist standardmäßig Gold. Rot nur für destruktive
|
||||||
|
// Aktionen (Löschen, Entfernen), per opts.danger = true.
|
||||||
|
const okBtn = document.getElementById("confirmOkBtn");
|
||||||
|
if (okBtn) {
|
||||||
|
okBtn.classList.toggle("btn-danger", !!opts.danger);
|
||||||
|
okBtn.classList.toggle("btn-primary", !opts.danger);
|
||||||
|
}
|
||||||
// Backward-compat: legacy Callback wird bei OK aufgerufen
|
// Backward-compat: legacy Callback wird bei OK aufgerufen
|
||||||
confirmCallback = callback || null;
|
confirmCallback = callback || null;
|
||||||
openModal("modalConfirm");
|
openModal("modalConfirm");
|
||||||
@@ -889,7 +901,10 @@ function esc(str) {
|
|||||||
if (!str) return "";
|
if (!str) return "";
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.textContent = str;
|
div.textContent = str;
|
||||||
return div.innerHTML;
|
// Auch Anführungszeichen maskieren. Viele Templates setzen esc() in
|
||||||
|
// HTML-Attribute (onclick='...', title="...") ein, dort bricht ein
|
||||||
|
// Apostroph im Quellennamen (z.B. L'Express) sonst das Markup.
|
||||||
|
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso) {
|
function formatDate(iso) {
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ async function refreshTasksBadge() {
|
|||||||
} catch (_) { /* Badge ist nicht kritisch */ }
|
} catch (_) { /* Badge ist nicht kritisch */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Badge schon beim Seitenstart füllen, nicht erst beim Öffnen des Quellen-Reiters
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
if (window.API && localStorage.getItem("token")) refreshTasksBadge();
|
||||||
|
});
|
||||||
|
|
||||||
// --- Aufgaben laden ---
|
// --- Aufgaben laden ---
|
||||||
async function loadAufgaben() {
|
async function loadAufgaben() {
|
||||||
renderAufgabenFilters();
|
renderAufgabenFilters();
|
||||||
@@ -137,7 +142,7 @@ function renderAufgabenSuggestions() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td><span class="badge badge-suggestion-${s.suggestion_type}">${SUGGESTION_TYPE_LABELS[s.suggestion_type] || s.suggestion_type}</span></td>
|
<td><span class="badge badge-suggestion-${s.suggestion_type}">${SUGGESTION_TYPE_LABELS[s.suggestion_type] || s.suggestion_type}</span></td>
|
||||||
<td>${esc(s.title)}</td>
|
<td>${esc(s.title)}</td>
|
||||||
<td class="text-secondary" style="max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="${esc(s.description || "")}">${esc(s.description || "")}</td>
|
<td class="text-secondary" style="max-width:420px; white-space:normal; overflow-wrap:anywhere;">${esc(s.description || "")}</td>
|
||||||
<td><span class="badge badge-priority-${s.priority}">${PRIORITY_LABELS[s.priority] || s.priority}</span></td>
|
<td><span class="badge badge-priority-${s.priority}">${PRIORITY_LABELS[s.priority] || s.priority}</span></td>
|
||||||
<td class="text-secondary">${formatDateTime(s.created_at)}</td>
|
<td class="text-secondary">${formatDateTime(s.created_at)}</td>
|
||||||
<td style="white-space:nowrap;">
|
<td style="white-space:nowrap;">
|
||||||
@@ -282,7 +287,7 @@ async function searchFix(btn) {
|
|||||||
showToast("Fehler. " + err.message, "error");
|
showToast("Fehler. " + err.message, "error");
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = "Lösung suchen";
|
btn.innerHTML = LUCIDE_ICONS.search; // Icon wiederherstellen, kein Text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +345,14 @@ function renderClassificationList() {
|
|||||||
: all;
|
: all;
|
||||||
const countEl = document.getElementById("reviewPendingCount");
|
const countEl = document.getElementById("reviewPendingCount");
|
||||||
if (countEl) countEl.textContent = String(all.length);
|
if (countEl) countEl.textContent = String(all.length);
|
||||||
|
// Sammel-Genehmigung nur anbieten, wenn überhaupt etwas aussteht
|
||||||
|
const bulkBtn = document.getElementById("bulkApproveBtn");
|
||||||
|
if (bulkBtn) {
|
||||||
|
bulkBtn.disabled = all.length === 0;
|
||||||
|
bulkBtn.title = all.length === 0
|
||||||
|
? "Erst aktiv, wenn Klassifikations-Vorschläge ausstehen"
|
||||||
|
: "Alle Vorschläge ab 0.85 Konfidenz übernehmen";
|
||||||
|
}
|
||||||
if (all.length === 0) {
|
if (all.length === 0) {
|
||||||
list.innerHTML = '<div class="text-muted" style="padding:24px;text-align:center;">Keine ausstehenden Klassifikationen.</div>';
|
list.innerHTML = '<div class="text-muted" style="padding:24px;text-align:center;">Keine ausstehenden Klassifikationen.</div>';
|
||||||
return;
|
return;
|
||||||
@@ -444,7 +457,9 @@ async function reclassifySource(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function triggerBulkClassify() {
|
async function triggerBulkClassify() {
|
||||||
if (!confirm("Bulk-Klassifikation aller noch nicht klassifizierten Quellen starten? Läuft im Hintergrund (~3-5 Sek pro Quelle, ~0.02 USD pro Quelle).")) return;
|
const ok = await showConfirm("Klassifikation starten",
|
||||||
|
"Bulk-Klassifikation aller noch nicht klassifizierten Quellen starten? Läuft im Hintergrund (~3-5 Sek pro Quelle, ~0.02 USD pro Quelle).");
|
||||||
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
const r = await API.post("/api/sources/classification/bulk-classify?limit=500&only_unclassified=true", {});
|
const r = await API.post("/api/sources/classification/bulk-classify?limit=500&only_unclassified=true", {});
|
||||||
showToast(`Bulk-Klassifikation gestartet (limit=${r.limit}). In ~10 min neu laden.`, "info");
|
showToast(`Bulk-Klassifikation gestartet (limit=${r.limit}). In ~10 min neu laden.`, "info");
|
||||||
@@ -454,7 +469,9 @@ async function triggerBulkClassify() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function bulkApproveHighConfidence() {
|
async function bulkApproveHighConfidence() {
|
||||||
if (!confirm("Alle Vorschläge mit Konfidenz ≥ 0.85 genehmigen?")) return;
|
const ok = await showConfirm("Sammel-Genehmigung",
|
||||||
|
"Alle Vorschläge mit Konfidenz ≥ 0.85 genehmigen?");
|
||||||
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
const r = await API.post("/api/sources/classification/bulk-approve?min_confidence=0.85", {});
|
const r = await API.post("/api/sources/classification/bulk-approve?min_confidence=0.85", {});
|
||||||
showToast(`${r.approved} Vorschläge übernommen.`, "success");
|
showToast(`${r.approved} Vorschläge übernommen.`, "success");
|
||||||
@@ -466,7 +483,9 @@ async function bulkApproveHighConfidence() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function triggerExternalReputationSync() {
|
async function triggerExternalReputationSync() {
|
||||||
if (!confirm("IFCN- und EUvsDisinfo-Datenbanken jetzt syncen? Läuft im Hintergrund (~30 Sek).")) return;
|
const ok = await showConfirm("Externe Daten syncen",
|
||||||
|
"IFCN- und EUvsDisinfo-Datenbanken jetzt syncen? Läuft im Hintergrund (~30 Sek).");
|
||||||
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
await API.post("/api/sources/external-reputation/sync", {});
|
await API.post("/api/sources/external-reputation/sync", {});
|
||||||
showToast("Externer Sync gestartet. Quellenliste in ~30 Sek neu laden.", "info");
|
showToast("Externer Sync gestartet. Quellenliste in ~30 Sek neu laden.", "info");
|
||||||
|
|||||||
@@ -175,7 +175,11 @@ function renderAuditEntries(items) {
|
|||||||
function formatDateTime(iso) {
|
function formatDateTime(iso) {
|
||||||
if (!iso) return "-";
|
if (!iso) return "-";
|
||||||
try {
|
try {
|
||||||
const d = new Date(iso);
|
let v = String(iso);
|
||||||
|
// SQLite liefert CURRENT_TIMESTAMP als UTC ohne Zeitzonen-Kennung.
|
||||||
|
// Ohne das Z würde der Browser den Wert als Lokalzeit fehlinterpretieren.
|
||||||
|
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(v)) v = v.replace(" ", "T") + "Z";
|
||||||
|
const d = new Date(v);
|
||||||
return d.toLocaleString("de-DE", {
|
return d.toLocaleString("de-DE", {
|
||||||
day: "2-digit", month: "2-digit", year: "numeric",
|
day: "2-digit", month: "2-digit", year: "numeric",
|
||||||
hour: "2-digit", minute: "2-digit",
|
hour: "2-digit", minute: "2-digit",
|
||||||
@@ -192,10 +196,8 @@ function renderUnifiedStats(stats) {
|
|||||||
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 || {};
|
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.global || 0}</span> Grundquellen</span>`);
|
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${bo.tenant || 0}</span> Kundenquellen</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>`);
|
||||||
@@ -398,6 +400,14 @@ function filterUnifiedSources() {
|
|||||||
vb = parseInt(vb) || 0;
|
vb = parseInt(vb) || 0;
|
||||||
return globalSortAsc ? va - vb : vb - va;
|
return globalSortAsc ? va - vb : vb - va;
|
||||||
}
|
}
|
||||||
|
if (globalSortField === "health_status") {
|
||||||
|
// Nach Schweregrad sortieren (error > warning > ok > unbekannt),
|
||||||
|
// nicht alphabetisch
|
||||||
|
const RANK = { error: 3, warning: 2, ok: 1 };
|
||||||
|
va = RANK[va] || 0;
|
||||||
|
vb = RANK[vb] || 0;
|
||||||
|
return globalSortAsc ? va - vb : vb - va;
|
||||||
|
}
|
||||||
va = String(va).toLowerCase();
|
va = String(va).toLowerCase();
|
||||||
vb = String(vb).toLowerCase();
|
vb = String(vb).toLowerCase();
|
||||||
const cmp = va.localeCompare(vb, "de");
|
const cmp = va.localeCompare(vb, "de");
|
||||||
@@ -550,7 +560,8 @@ function confirmDeleteGlobalSource(id, name) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message, "error");
|
showToast(err.message, "error");
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
{ danger: true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ function confirmDeleteXScraper(username) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "Konto konnte nicht entfernt werden", "error");
|
showToast(err.message || "Konto konnte nicht entfernt werden", "error");
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
{ danger: true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,12 +52,20 @@ AUTH_PROTECTED = [
|
|||||||
("POST", "/api/sources/tenant/bulk-promote"),
|
("POST", "/api/sources/tenant/bulk-promote"),
|
||||||
("POST", "/api/sources/discover"),
|
("POST", "/api/sources/discover"),
|
||||||
("POST", "/api/sources/discover/add"),
|
("POST", "/api/sources/discover/add"),
|
||||||
("GET", "/api/sources/health"),
|
("GET", "/api/sources/health/history"),
|
||||||
("GET", "/api/sources/suggestions"),
|
("GET", "/api/sources/suggestions"),
|
||||||
("PUT", "/api/sources/suggestions/1"),
|
("PUT", "/api/sources/suggestions/1"),
|
||||||
("POST", "/api/sources/health/run"),
|
("POST", "/api/sources/health/run"),
|
||||||
("GET", "/api/sources/health/run-status"),
|
("GET", "/api/sources/health/run-status"),
|
||||||
("POST", "/api/sources/health/search-fix/1"),
|
("POST", "/api/sources/health/search-fix/1"),
|
||||||
|
("GET", "/api/sources/classification/queue"),
|
||||||
|
("POST", "/api/sources/classification/bulk-classify"),
|
||||||
|
("POST", "/api/sources/classification/bulk-approve"),
|
||||||
|
("POST", "/api/sources/1/classification/approve"),
|
||||||
|
("POST", "/api/sources/1/classification/reject"),
|
||||||
|
("POST", "/api/sources/1/classification/reclassify"),
|
||||||
|
("POST", "/api/sources/external-reputation/sync"),
|
||||||
|
("POST", "/api/sources/global/upload-pdf"),
|
||||||
("GET", "/api/statistik"),
|
("GET", "/api/statistik"),
|
||||||
("GET", "/api/token-usage/overview"),
|
("GET", "/api/token-usage/overview"),
|
||||||
("GET", "/api/token-usage/1"),
|
("GET", "/api/token-usage/1"),
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren