ux(quellen-health): Default "Nur Probleme", Counter feiner gegliedert, Filter-Hint bei Pagination

Schritt 1 der Quellen-Health-Aufraeumung. Drei UX-Verbesserungen, kein Daten-Eingriff:

1. Default-Filter "Nur Probleme" (errors + warnings, ohne OK).
   - Neuer Status-Filter-Wert "issues" als virtuelles Frontend-Konstrukt.
   - applyHealthFilter behandelt "issues" als status != ok.
   - Default in healthFilters ist jetzt "issues". User sieht beim
     Tab-Klick sofort die kritischen 146 Eintraege statt der 281
     gruenen OK-Zeilen.

2. Counter aufgegliedert nach check_type.
   - Backend (/api/sources/health): zusaetzliches Feld "breakdown"
     mit der GROUP-BY (check_type, status) Aggregation.
   - Frontend rendert pro Status-Zeile die feine Aufschluesselung,
     z.B. "143 Warnungen (112 Aktualität, 27 Feed-Validität, 3 Duplikat,
     1 Erreichbarkeit)".
   - Hilft dem Admin, sofort zu sehen wo das Problem liegt.

3. Filter-Hint bei Pagination + leeren Treffern.
   - Wenn der aktuelle Filter ueber die geladenen 100 Items keinen
     Treffer findet UND has_more=true, zeigt das Frontend einen
     Hinweis-Link "Alle X Health-Checks laden und Filter erneut
     anwenden".
   - Loest das Edge-Problem, dass z.B. Filter "Nur OK" auf den
     Default-100 (errors first) leer schien.

Cache-Buster fuer source-health.js auf 20260509g gebumpt.
Dieser Commit ist enthalten in:
claude-dev
2026-05-09 13:24:44 +00:00
Ursprung 50749323f8
Commit e8bb2495ee
3 geänderte Dateien mit 53 neuen und 11 gelöschten Zeilen

Datei anzeigen

@@ -594,14 +594,25 @@ async def get_health(
"limit": limit, "offset": offset, "has_more": False, "limit": limit, "offset": offset, "has_more": False,
} }
# Aggregate über GESAMTEN Bestand (eine GROUP-BY-Query, billig) # 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( cursor = await db.execute(
"SELECT status, COUNT(*) AS n FROM source_health_checks GROUP BY status" "SELECT check_type, status, COUNT(*) AS n FROM source_health_checks GROUP BY check_type, status"
) )
counts = {row["status"]: row["n"] for row in await cursor.fetchall()} breakdown = {} # {check_type: {status: count}}
error_count = counts.get("error", 0) error_count = 0
warning_count = counts.get("warning", 0) warning_count = 0
ok_count = counts.get("ok", 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 total_checks = error_count + warning_count + ok_count
# Paginierte Daten # Paginierte Daten
@@ -641,6 +652,7 @@ async def get_health(
"errors": error_count, "errors": error_count,
"warnings": warning_count, "warnings": warning_count,
"ok": ok_count, "ok": ok_count,
"breakdown": breakdown,
"checks": checks, "checks": checks,
"all_orgs": all_orgs, "all_orgs": all_orgs,
"limit": limit, "limit": limit,

Datei anzeigen

@@ -715,7 +715,7 @@
<script src="/static/js/app.js?v=20260509d"></script> <script src="/static/js/app.js?v=20260509d"></script>
<script src="/static/js/sources.js?v=20260509d"></script> <script src="/static/js/sources.js?v=20260509d"></script>
<script src="/static/js/source-health.js?v=20260509f"></script> <script src="/static/js/source-health.js?v=20260509g"></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>

Datei anzeigen

@@ -3,7 +3,9 @@
let healthData = null; let healthData = null;
let suggestionsCache = []; let suggestionsCache = [];
let healthFilters = { status: "", check_type: "", org: "all" }; // Default-Filter zeigt nur Probleme (errors + warnings); OK ist meistens Rauschen.
// "issues" ist ein virtueller Status-Wert, den nur das Frontend versteht (siehe applyHealthFilter).
let healthFilters = { status: "issues", check_type: "", org: "all" };
let healthHistoryCache = []; let healthHistoryCache = [];
// 60-Sekunden-Cache, damit Tab-Wechsel nicht jedes Mal die volle Antwort neu lädt. // 60-Sekunden-Cache, damit Tab-Wechsel nicht jedes Mal die volle Antwort neu lädt.
@@ -91,7 +93,9 @@ function loadAllHealth() {
function applyHealthFilter(checks) { function applyHealthFilter(checks) {
return checks.filter(c => { return checks.filter(c => {
if (healthFilters.status && c.status !== healthFilters.status) return false; // "issues" = Sammelfilter für errors + warnings (Default)
if (healthFilters.status === "issues" && c.status === "ok") return false;
if (healthFilters.status && healthFilters.status !== "issues" && c.status !== healthFilters.status) return false;
if (healthFilters.check_type && c.check_type !== healthFilters.check_type) return false; if (healthFilters.check_type && c.check_type !== healthFilters.check_type) return false;
if (healthFilters.org === "global" && c.tenant_id !== null) return false; if (healthFilters.org === "global" && c.tenant_id !== null) return false;
if (healthFilters.org !== "all" && healthFilters.org !== "global" if (healthFilters.org !== "all" && healthFilters.org !== "global"
@@ -208,6 +212,21 @@ function renderHealthDashboard() {
const checkTypes = Array.from(new Set(allChecks.map(c => c.check_type))); const checkTypes = Array.from(new Set(allChecks.map(c => c.check_type)));
// Counter-Aufgliederung aus Backend-Breakdown (pro check_type x status).
// Beispiel: { reachability: {ok: 281, error: 3, warning: 1}, feed_validity: {...}, stale: {...}, duplicate: {...} }
const breakdown = healthData.breakdown || {};
function breakdownLine(statusKey, cssClass) {
const entries = Object.entries(breakdown)
.map(([ct, byStatus]) => [ct, byStatus[statusKey] || 0])
.filter(([_, n]) => n > 0)
.sort((a, b) => b[1] - a[1]);
if (entries.length === 0) return "";
const total = entries.reduce((s, [, n]) => s + n, 0);
const detail = entries.map(([ct, n]) => `${n} ${CHECK_TYPE_LABELS[ct] || ct}`).join(", ");
const label = statusKey === "error" ? "Fehler" : (statusKey === "warning" ? "Warnungen" : "OK");
return `<span class="${cssClass}" title="${esc(detail)}">${total} ${label}</span> <span class="text-secondary" style="font-size:11px;">(${esc(detail)})</span>`;
}
healthHtml = ` healthHtml = `
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
@@ -215,14 +234,15 @@ function renderHealthDashboard() {
<span class="text-secondary" style="font-size:13px;"> <span class="text-secondary" style="font-size:13px;">
Letzter Check: ${healthData.last_check ? formatDateTime(healthData.last_check) : "Noch nie"} Letzter Check: ${healthData.last_check ? formatDateTime(healthData.last_check) : "Noch nie"}
&nbsp;|&nbsp; &nbsp;|&nbsp;
<span class="text-danger">${healthData.errors} Fehler</span> &nbsp; ${breakdownLine("error", "text-danger") || `<span class="text-danger">0 Fehler</span>`} &nbsp;
<span class="text-warning">${healthData.warnings} Warnungen</span> &nbsp; ${breakdownLine("warning", "text-warning") || `<span class="text-warning">0 Warnungen</span>`} &nbsp;
<span class="text-success">${okCount} OK</span> <span class="text-success">${okCount} OK</span>
</span> </span>
</div> </div>
<div class="action-bar" style="border-bottom:1px solid var(--border, rgba(255,255,255,0.08));"> <div class="action-bar" style="border-bottom:1px solid var(--border, rgba(255,255,255,0.08));">
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;"> <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
<select class="filter-select" onchange="setHealthFilter('status', this.value)"> <select class="filter-select" onchange="setHealthFilter('status', this.value)">
<option value="issues" ${healthFilters.status === "issues" ? "selected" : ""}>Nur Probleme (Default)</option>
<option value="" ${healthFilters.status === "" ? "selected" : ""}>Alle Status</option> <option value="" ${healthFilters.status === "" ? "selected" : ""}>Alle Status</option>
<option value="error" ${healthFilters.status === "error" ? "selected" : ""}>Nur Fehler</option> <option value="error" ${healthFilters.status === "error" ? "selected" : ""}>Nur Fehler</option>
<option value="warning" ${healthFilters.status === "warning" ? "selected" : ""}>Nur Warnungen</option> <option value="warning" ${healthFilters.status === "warning" ? "selected" : ""}>Nur Warnungen</option>
@@ -269,6 +289,16 @@ function renderHealthDashboard() {
</tbody> </tbody>
</table> </table>
</div>`; </div>`;
} else if (hasMore) {
// 0 Treffer in der Page, aber es gibt noch ungeladene Items.
// Hinweis, dass der Filter erst über die volle Liste sicher ist.
healthHtml += `
<div class="card-body text-muted">
Keine Treffer in den geladenen ${allChecks.length} von ${totalAll} Items mit dem aktuellen Filter.
<a href="#" onclick="event.preventDefault(); loadAllHealth()" style="text-decoration:underline;">
Alle ${totalAll} Health-Checks laden
</a> und Filter erneut anwenden.
</div>`;
} else { } else {
healthHtml += '<div class="card-body text-muted">Keine Ergebnisse mit diesen Filtern.</div>'; healthHtml += '<div class="card-body text-muted">Keine Ergebnisse mit diesen Filtern.</div>';
} }