Quellen-Health UI: Tab, API-Endpunkte, Vorschläge

- Neuer Sub-Tab "Quellen-Health" mit Vorschlägen + Check-Ergebnissen
- API: GET /health, GET /suggestions, PUT /suggestions/{id}, POST /health/run
- Vorschläge annehmen/ablehnen mit Auto-Ausführung
- Badge-Styles für Health-Status und Prioritäten
- Umlaute in Source-Modal und Dashboard korrigiert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-03-08 15:26:31 +01:00
Ursprung dbd5568296
Commit 7045a5c657
6 geänderte Dateien mit 541 neuen und 11 gelöschten Zeilen

Datei anzeigen

@@ -35,3 +35,4 @@ MAGIC_LINK_EXPIRE_MINUTES = 10
CLAUDE_PATH = os.environ.get("CLAUDE_PATH", "/home/claude-dev/.claude/local/claude")
CLAUDE_TIMEOUT = 300
MAX_FEEDS_PER_DOMAIN = 3
CLAUDE_MODEL_FAST = "claude-haiku-4-5-20251001"

Datei anzeigen

@@ -1,3 +1,4 @@
import os
"""Grundquellen-Verwaltung und Kundenquellen-Übersicht."""
import sys
import logging
@@ -12,6 +13,8 @@ from auth import get_current_admin
from database import db_dependency
import aiosqlite
sys.path.insert(0, os.path.join('/home/claude-dev/AegisSight-Monitor/src'))
from source_rules import (
discover_source,
discover_all_feeds,
@@ -312,3 +315,204 @@ async def add_discovered_sources(
await db.commit()
return {"added": added, "skipped": skipped}
# --- Health-Check & Vorschläge ---
@router.get("/health")
async def get_health(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Health-Check-Ergebnisse abrufen."""
# 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": []}
cursor = await db.execute("""
SELECT
h.id, h.source_id, s.name, s.domain, s.url, s.source_type,
h.check_type, h.status, h.message, h.details, h.checked_at
FROM source_health_checks h
JOIN sources s ON s.id = h.source_id
ORDER BY
CASE h.status WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
s.name
""")
checks = [dict(row) for row in await cursor.fetchall()]
error_count = sum(1 for c in checks if c["status"] == "error")
warning_count = sum(1 for c in checks if c["status"] == "warning")
ok_count = sum(1 for c in checks if c["status"] == "ok")
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": len(checks),
"errors": error_count,
"warnings": warning_count,
"ok": ok_count,
"checks": checks,
}
@router.get("/suggestions")
async def get_suggestions(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle Vorschläge abrufen (pending zuerst, dann letzte 20 bearbeitete)."""
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='source_suggestions'"
)
if not await cursor.fetchone():
return []
cursor = await db.execute("""
SELECT * FROM source_suggestions
ORDER BY
CASE status WHEN 'pending' THEN 0 ELSE 1 END,
created_at DESC
LIMIT 50
""")
return [dict(row) for row in await cursor.fetchall()]
class SuggestionAction(BaseModel):
accept: bool
@router.put("/suggestions/{suggestion_id}")
async def update_suggestion(
suggestion_id: int,
action: SuggestionAction,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Vorschlag annehmen oder ablehnen."""
import json as _json
cursor = await db.execute(
"SELECT * FROM source_suggestions WHERE id = ?", (suggestion_id,)
)
suggestion = await cursor.fetchone()
if not suggestion:
raise HTTPException(status_code=404, detail="Vorschlag nicht gefunden")
suggestion = dict(suggestion)
if suggestion["status"] != "pending":
raise HTTPException(status_code=400, detail=f"Vorschlag bereits {suggestion['status']}")
new_status = "accepted" if action.accept else "rejected"
result_action = None
if action.accept:
stype = suggestion["suggestion_type"]
data = _json.loads(suggestion["suggested_data"]) if suggestion["suggested_data"] else {}
if stype == "add_source":
name = data.get("name", "Unbenannt")
url = data.get("url")
domain = data.get("domain", "")
category = data.get("category", "sonstige")
source_type = "rss_feed" if url and any(
x in (url or "").lower() for x in ("rss", "feed", "xml", "atom")
) else "web_source"
if url:
cursor = await db.execute(
"SELECT id FROM sources WHERE url = ? AND tenant_id IS NULL", (url,)
)
if await cursor.fetchone():
result_action = "übersprungen (URL bereits vorhanden)"
new_status = "rejected"
else:
await db.execute(
"INSERT INTO sources (name, url, domain, source_type, category, status, added_by, tenant_id) "
"VALUES (?, ?, ?, ?, ?, 'active', 'haiku-vorschlag', NULL)",
(name, url, domain, source_type, category),
)
result_action = f"Quelle '{name}' angelegt"
else:
result_action = "übersprungen (keine URL)"
new_status = "rejected"
elif stype == "deactivate_source":
source_id = suggestion["source_id"]
if source_id:
await db.execute("UPDATE sources SET status = 'inactive' WHERE id = ?", (source_id,))
result_action = "Quelle deaktiviert"
elif stype == "remove_source":
source_id = suggestion["source_id"]
if source_id:
await db.execute("DELETE FROM sources WHERE id = ?", (source_id,))
result_action = "Quelle gelöscht"
elif stype == "fix_url":
source_id = suggestion["source_id"]
new_url = data.get("url")
if source_id and new_url:
await db.execute("UPDATE sources SET url = ? WHERE id = ?", (new_url, source_id))
result_action = f"URL aktualisiert"
await db.execute(
"UPDATE source_suggestions SET status = ?, reviewed_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_status, suggestion_id),
)
await db.commit()
return {"status": new_status, "action": result_action}
@router.post("/health/run")
async def run_health_check_now(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Health-Check manuell starten."""
# Tabellen sicherstellen
await db.executescript("""
CREATE TABLE IF NOT EXISTS source_health_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
check_type TEXT NOT NULL,
status TEXT NOT NULL,
message TEXT,
details TEXT,
checked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS source_suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
suggestion_type TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
source_id INTEGER REFERENCES sources(id) ON DELETE SET NULL,
suggested_data TEXT,
priority TEXT DEFAULT 'medium',
status TEXT DEFAULT 'pending',
reviewed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
await db.commit()
# source_health und source_suggester importieren
sys.path.insert(0, "/home/claude-dev/AegisSight-Monitor/src")
from services.source_health import run_health_checks
from services.source_suggester import generate_suggestions
result = await run_health_checks(db)
suggestion_count = await generate_suggestions(db)
return {
"checked": result["checked"],
"issues": result["issues"],
"suggestions": suggestion_count,
}

Datei anzeigen

@@ -651,3 +651,55 @@ tr:hover td {
line-height: 1.6;
margin-bottom: 8px;
}
/* --- 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 {
background: rgba(59, 130, 246, 0.2);
color: #93c5fd;
}
.badge-suggestion-deactivate_source {
background: rgba(245, 158, 11, 0.2);
color: #fcd34d;
}
.badge-suggestion-remove_source {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
.badge-suggestion-fix_url {
background: rgba(168, 85, 247, 0.2);
color: #d8b4fe;
}
.badge-priority-high {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
.badge-priority-medium {
background: rgba(245, 158, 11, 0.2);
color: #fcd34d;
}
.badge-priority-low {
background: rgba(100, 116, 139, 0.2);
color: #94a3b8;
}

Datei anzeigen

@@ -43,7 +43,7 @@
<div class="card">
<div class="card-header">
<h2>Letzte Aktivitaet</h2>
<h2>Letzte Aktivität</h2>
</div>
<div class="card-body" id="recentActivity">
<div class="text-muted">Laden...</div>
@@ -81,7 +81,7 @@
<!-- Org Detail -->
<div class="detail-panel" id="orgDetail">
<button class="detail-back" id="orgBackBtn">&larr; Zurueck</button>
<button class="detail-back" id="orgBackBtn">&larr; Zurück</button>
<div id="orgDetailHeader"></div>
<div class="nav-tabs mt-16" id="orgDetailTabs">
@@ -126,8 +126,8 @@
<tr>
<th>Typ</th>
<th>Max Nutzer</th>
<th>Gueltig ab</th>
<th>Gueltig bis</th>
<th>Gültig ab</th>
<th>Gültig bis</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
@@ -156,7 +156,7 @@
</div>
<div style="display: flex; gap: 8px; margin-top: 16px;">
<button type="submit" class="btn btn-primary">Speichern</button>
<button type="button" class="btn btn-danger" id="deleteOrgBtn">Organisation loeschen</button>
<button type="button" class="btn btn-danger" id="deleteOrgBtn">Organisation löschen</button>
</div>
</form>
</div>
@@ -200,6 +200,7 @@
<div class="nav-tabs" id="sourceSubTabs">
<button class="nav-tab active" data-subtab="global-sources">Grundquellen</button>
<button class="nav-tab" data-subtab="tenant-sources">Kundenquellen</button>
<button class="nav-tab" data-subtab="source-health">Quellen-Health</button>
</div>
<!-- Grundquellen -->
@@ -283,6 +284,17 @@
</div>
</div>
</div>
<!-- Quellen-Health -->
<div class="section" id="sub-source-health">
<div class="action-bar">
<h2 style="font-size:16px;font-weight:600;">Quellen-Health & Vorschläge</h2>
<button class="btn btn-primary" id="runHealthCheckBtn" onclick="runHealthCheck()">Jetzt prüfen</button>
</div>
<div id="healthContent">
<div class="text-muted" style="padding:20px;">Tab auswählen um Health-Daten zu laden...</div>
</div>
</div>
</div>
<!-- Modal: New Organization -->
@@ -406,16 +418,16 @@
<select id="sourceType">
<option value="rss_feed">RSS-Feed</option>
<option value="web_source">Webquelle</option>
<option value="excluded">Gesperrt</option>
<option value="excluded">Ausgeschlossen</option>
</select>
</div>
<div class="form-group">
<label for="sourceCategory">Kategorie</label>
<select id="sourceCategory">
<option value="nachrichtenagentur">Nachrichtenagentur</option>
<option value="oeffentlich-rechtlich">Oeffentlich-Rechtlich</option>
<option value="qualitaetszeitung">Qualitaetszeitung</option>
<option value="behoerde">Behoerde</option>
<option value="oeffentlich-rechtlich">Öffentlich-Rechtlich</option>
<option value="qualitaetszeitung">Qualitätszeitung</option>
<option value="behoerde">Behörde</option>
<option value="fachmedien">Fachmedien</option>
<option value="think-tank">Think-Tank</option>
<option value="international">International</option>
@@ -477,7 +489,7 @@
<div class="modal-overlay" id="modalConfirm">
<div class="modal" style="max-width: 400px;">
<div class="modal-header">
<h3 id="confirmTitle">Bestaetigung</h3>
<h3 id="confirmTitle">Bestätigung</h3>
<button class="modal-close" onclick="closeModal('modalConfirm')">&times;</button>
</div>
<div class="modal-body">
@@ -485,12 +497,13 @@
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modalConfirm')">Abbrechen</button>
<button class="btn btn-danger" id="confirmOkBtn">Bestaetigen</button>
<button class="btn btn-danger" id="confirmOkBtn">Bestätigen</button>
</div>
</div>
</div>
<script src="/static/js/app.js"></script>
<script src="/static/js/sources.js"></script>
<script src="/static/js/source-health.js"></script>
</body>
</html>

259
src/static/js/source-health.js Normale Datei
Datei anzeigen

@@ -0,0 +1,259 @@
/* Quellen-Health & Vorschläge */
"use strict";
let healthData = null;
let suggestionsCache = [];
const CHECK_TYPE_LABELS = {
reachability: "Erreichbarkeit",
feed_validity: "Feed-Validität",
stale: "Aktualität",
duplicate: "Duplikat",
};
const SUGGESTION_TYPE_LABELS = {
add_source: "Neue Quelle",
deactivate_source: "Deaktivieren",
remove_source: "Entfernen",
fix_url: "URL korrigieren",
};
const PRIORITY_LABELS = {
high: "Hoch",
medium: "Mittel",
low: "Niedrig",
};
// --- Init ---
function setupHealthTab() {
const tab = document.querySelector('#sourceSubTabs .nav-tab[data-subtab="source-health"]');
if (tab) {
tab.addEventListener("click", () => loadHealthData());
}
}
document.addEventListener("DOMContentLoaded", setupHealthTab);
// --- Health-Daten laden ---
async function loadHealthData() {
try {
const [health, suggestions] = await Promise.all([
API.get("/api/sources/health"),
API.get("/api/sources/suggestions"),
]);
healthData = health;
suggestionsCache = suggestions;
renderHealthDashboard();
} catch (err) {
console.error("Health-Daten laden fehlgeschlagen:", err);
document.getElementById("healthContent").innerHTML =
'<div class="text-muted" style="padding:20px;">Fehler beim Laden der Health-Daten.</div>';
}
}
function renderHealthDashboard() {
const container = document.getElementById("healthContent");
if (!container) return;
// Vorschläge rendern
const pendingSuggestions = suggestionsCache.filter((s) => s.status === "pending");
const recentSuggestions = suggestionsCache.filter((s) => s.status !== "pending");
let suggestionsHtml = "";
if (pendingSuggestions.length > 0) {
suggestionsHtml = `
<div class="card" style="margin-bottom:16px;">
<div class="card-header">
<h2>Vorschläge (${pendingSuggestions.length} offen)</h2>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Typ</th>
<th>Titel</th>
<th>Beschreibung</th>
<th>Priorität</th>
<th>Erstellt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
${pendingSuggestions
.map(
(s) => `
<tr>
<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 class="text-secondary" style="max-width:300px;">${esc(s.description || "")}</td>
<td><span class="badge badge-priority-${s.priority}">${PRIORITY_LABELS[s.priority] || s.priority}</span></td>
<td class="text-secondary">${formatDate(s.created_at)}</td>
<td style="white-space:nowrap;">
<button class="btn btn-success btn-small" onclick="handleSuggestion(${s.id}, true)">Annehmen</button>
<button class="btn btn-danger btn-small" onclick="handleSuggestion(${s.id}, false)">Ablehnen</button>
</td>
</tr>`,
)
.join("")}
</tbody>
</table>
</div>
</div>`;
} else {
suggestionsHtml = `
<div class="card" style="margin-bottom:16px;">
<div class="card-header"><h2>Vorschläge</h2></div>
<div class="card-body text-muted">Keine offenen Vorschläge vorhanden.</div>
</div>`;
}
// Vergangene Vorschläge
let historyHtml = "";
if (recentSuggestions.length > 0) {
historyHtml = `
<div class="card" style="margin-bottom:16px;">
<div class="card-header"><h2>Verlauf</h2></div>
<div class="table-wrap">
<table>
<thead>
<tr><th>Typ</th><th>Titel</th><th>Status</th><th>Bearbeitet</th></tr>
</thead>
<tbody>
${recentSuggestions
.slice(0, 20)
.map(
(s) => `
<tr>
<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><span class="badge badge-${s.status === "accepted" ? "active" : "inactive"}">${s.status === "accepted" ? "Angenommen" : "Abgelehnt"}</span></td>
<td class="text-secondary">${formatDate(s.reviewed_at)}</td>
</tr>`,
)
.join("")}
</tbody>
</table>
</div>
</div>`;
}
// Health-Check Ergebnisse
let healthHtml = "";
if (healthData && healthData.checks && healthData.checks.length > 0) {
const issues = healthData.checks.filter((c) => c.status !== "ok");
const okCount = healthData.checks.filter((c) => c.status === "ok").length;
healthHtml = `
<div class="card">
<div class="card-header">
<h2>Health-Check Ergebnisse</h2>
<span class="text-secondary" style="font-size:13px;">
Letzter Check: ${healthData.last_check ? formatDate(healthData.last_check) : "Noch nie"}
&nbsp;|&nbsp;
<span class="text-danger">${healthData.errors} Fehler</span> &nbsp;
<span class="text-warning">${healthData.warnings} Warnungen</span> &nbsp;
<span class="text-success">${okCount} OK</span>
</span>
</div>`;
if (issues.length > 0) {
healthHtml += `
<div class="table-wrap">
<table>
<thead>
<tr><th>Quelle</th><th>Domain</th><th>Typ</th><th>Status</th><th>Details</th></tr>
</thead>
<tbody>
${issues
.map(
(c) => `
<tr>
<td>${esc(c.name)}</td>
<td class="text-secondary">${esc(c.domain || "")}</td>
<td>${CHECK_TYPE_LABELS[c.check_type] || c.check_type}</td>
<td><span class="badge badge-health-${c.status}">${c.status === "error" ? "Fehler" : "Warnung"}</span></td>
<td class="text-secondary" style="max-width:300px;">${esc(c.message)}</td>
</tr>`,
)
.join("")}
</tbody>
</table>
</div>`;
} else {
healthHtml += '<div class="card-body text-success">Alle Quellen sind gesund.</div>';
}
healthHtml += "</div>";
} else {
healthHtml = `
<div class="card">
<div class="card-header"><h2>Health-Check Ergebnisse</h2></div>
<div class="card-body text-muted">Noch kein Health-Check durchgeführt.</div>
</div>`;
}
container.innerHTML = suggestionsHtml + historyHtml + healthHtml;
}
// --- Vorschlag annehmen/ablehnen ---
async function handleSuggestion(id, accept) {
const action = accept ? "annehmen" : "ablehnen";
const suggestion = suggestionsCache.find((s) => s.id === id);
if (!suggestion) return;
if (!confirm(`Vorschlag "${suggestion.title}" ${action}?`)) return;
try {
const result = await API.put("/api/sources/suggestions/" + id, { accept });
if (result.action) {
alert(`Ergebnis: ${result.action}`);
}
loadHealthData();
// Grundquellen-Liste auch aktualisieren
if (typeof loadGlobalSources === "function") loadGlobalSources();
} catch (err) {
alert("Fehler: " + err.message);
}
}
// --- Health-Check manuell starten ---
async function runHealthCheck() {
const btn = document.getElementById("runHealthCheckBtn");
if (btn) {
btn.disabled = true;
btn.textContent = "Läuft...";
}
try {
const result = await API.post("/api/sources/health/run");
alert(
`Health-Check abgeschlossen: ${result.checked} Quellen geprüft, ` +
`${result.issues} Probleme gefunden. ` +
`${result.suggestions} neue Vorschläge generiert.`,
);
loadHealthData();
} catch (err) {
alert("Fehler: " + err.message);
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = "Jetzt prüfen";
}
}
}
// --- Hilfsfunktionen ---
function formatDate(dateStr) {
if (!dateStr) return "-";
try {
const d = new Date(dateStr);
return d.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch (_) {
return dateStr;
}
}

Datei anzeigen

@@ -48,6 +48,7 @@ function setupSourceSubTabs() {
if (subtab === "global-sources") loadGlobalSources();
else if (subtab === "tenant-sources") loadTenantSources();
else if (subtab === "source-health") loadHealthData();
});
});
}