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:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">← Zurueck</button>
|
||||
<button class="detail-back" id="orgBackBtn">← 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')">×</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
259
src/static/js/source-health.js
Normale Datei
@@ -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"}
|
||||
|
|
||||
<span class="text-danger">${healthData.errors} Fehler</span>
|
||||
<span class="text-warning">${healthData.warnings} Warnungen</span>
|
||||
<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;
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ function setupSourceSubTabs() {
|
||||
|
||||
if (subtab === "global-sources") loadGlobalSources();
|
||||
else if (subtab === "tenant-sources") loadTenantSources();
|
||||
else if (subtab === "source-health") loadHealthData();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren