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:
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;
|
||||
}
|
||||
}
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren