perf(sources): Quellen-Health Pagination (default 100, plus mehr/alle laden)

Echter Bottleneck war der DOM-Render von 519 Tabellen-Zeilen, nicht
das Backend (45ms). Backend-Slim und Cache aus dem letzten Commit
haben Bandbreite und wiederholte Klicks beschleunigt, aber der erste
Klick blieb langsam, weil weiterhin alle 519 Items in einem
innerHTML-Schub gerendert wurden.

Lösung: Server-Side-Pagination.

Backend (/api/sources/health):
- Neue Query-Param: limit (default 100, max 5000), offset (default 0)
- Counters errors/warnings/ok/total_checks aus separater GROUP-BY-
  Aggregat-Query über den GESAMTEN Bestand, nicht über die Page.
- Neues Feld all_orgs in der Antwort: alle Tenants mit Health-Checks,
  damit das Filter-Dropdown auch im Pagination-Modus die volle
  Org-Liste hat.
- Neue Felder limit, offset, has_more.

Frontend (source-health.js):
- healthLoadLimit (default 100), wird durch loadMoreHealth() um 200
  hochgesetzt oder durch loadAllHealth() auf alles gesetzt.
- Cache-Key beinhaltet jetzt auch das aktuelle Limit, damit beim
  Mehr-laden nicht aus altem Cache bedient wird.
- Org-Liste kommt aus healthData.all_orgs statt aus den geladenen
  Page-Items, sonst wäre sie nach Pagination unvollständig.
- Footer mit zwei Buttons ("+200 laden", "Alle N weiteren laden")
  unter der Tabelle, nur sichtbar bei has_more=true.
- Counter-Anzeige: "X / Y angezeigt (von Z insgesamt)".

Cache-Buster für source-health.js auf 20260509f gebumpt.
Dieser Commit ist enthalten in:
claude-dev
2026-05-09 12:54:35 +00:00
Ursprung f6af21e6cb
Commit 657683d491
3 geänderte Dateien mit 93 neuen und 20 gelöschten Zeilen

Datei anzeigen

@@ -8,9 +8,14 @@ let healthHistoryCache = [];
// 60-Sekunden-Cache, damit Tab-Wechsel nicht jedes Mal die volle Antwort neu lädt.
// Bei Mutationen (Vorschlag annehmen/ablehnen, run-stream, search-fix) wird mit force=true neu geladen.
let healthDataCache = { health: null, suggestions: null, history: null, ts: 0 };
// Cache-Key beinhaltet das aktuelle Limit, damit "Mehr laden" nicht aus altem Cache bedient wird.
let healthDataCache = { health: null, suggestions: null, history: null, ts: 0, limit: 0 };
const HEALTH_CACHE_TTL_MS = 60000;
// Default-Pagination: 100 Items reichen meistens (errors+warnings stehen vorne, ok-Status hinten).
// Wird durch loadMoreHealth() / loadAllHealth() hochgesetzt.
let healthLoadLimit = 100;
const CHECK_TYPE_LABELS = {
reachability: "Erreichbarkeit",
@@ -45,7 +50,10 @@ document.addEventListener("DOMContentLoaded", setupHealthTab);
// --- Health-Daten laden ---
async function loadHealthData(force = false) {
const now = Date.now();
if (!force && healthDataCache.health && (now - healthDataCache.ts) < HEALTH_CACHE_TTL_MS) {
if (!force
&& healthDataCache.health
&& healthDataCache.limit === healthLoadLimit
&& (now - healthDataCache.ts) < HEALTH_CACHE_TTL_MS) {
healthData = healthDataCache.health;
suggestionsCache = healthDataCache.suggestions;
healthHistoryCache = healthDataCache.history;
@@ -54,14 +62,14 @@ async function loadHealthData(force = false) {
}
try {
const [health, suggestions, history] = await Promise.all([
API.get("/api/sources/health"),
API.get("/api/sources/health?limit=" + healthLoadLimit),
API.get("/api/sources/suggestions"),
API.get("/api/sources/health/history?limit=10").catch(() => []),
]);
healthData = health;
suggestionsCache = suggestions;
healthHistoryCache = history || [];
healthDataCache = { health, suggestions, history: history || [], ts: Date.now() };
healthDataCache = { health, suggestions, history: history || [], ts: Date.now(), limit: healthLoadLimit };
renderHealthDashboard();
} catch (err) {
console.error("Health-Daten laden fehlgeschlagen:", err);
@@ -70,6 +78,17 @@ async function loadHealthData(force = false) {
}
}
// Pagination-Steuerung: hochsetzen + neu laden
function loadMoreHealth() {
healthLoadLimit += 200;
loadHealthData(true);
}
function loadAllHealth() {
healthLoadLimit = 100000;
loadHealthData(true);
}
function applyHealthFilter(checks) {
return checks.filter(c => {
if (healthFilters.status && c.status !== healthFilters.status) return false;
@@ -179,14 +198,13 @@ function renderHealthDashboard() {
// Filter anwenden
const allChecks = healthData.checks;
const filtered = applyHealthFilter(allChecks);
const okCount = healthData.checks.filter((c) => c.status === "ok").length;
// Counters aus Backend-Aggregat (über Gesamt-Bestand, nicht nur Page)
const okCount = healthData.ok != null ? healthData.ok : healthData.checks.filter((c) => c.status === "ok").length;
const totalAll = healthData.total_checks != null ? healthData.total_checks : allChecks.length;
const hasMore = !!healthData.has_more;
// Org-Liste fuer Dropdown
const orgs = Array.from(new Set(allChecks.map(c => c.tenant_id).filter(t => t != null)))
.map(tid => {
const c = allChecks.find(x => x.tenant_id === tid);
return { id: String(tid), name: c ? c.org_name : `Org ${tid}` };
});
// Org-Liste aus Backend-Liste (volle Liste, auch wenn Page kleiner ist)
const orgs = (healthData.all_orgs || []).map(o => ({ id: String(o.id), name: o.name || ("Org " + o.id) }));
const checkTypes = Array.from(new Set(allChecks.map(c => c.check_type)));
@@ -219,7 +237,9 @@ function renderHealthDashboard() {
<option value="global" ${healthFilters.org === "global" ? "selected" : ""}>Nur Grundquellen</option>
${orgs.map(o => `<option value="${esc(o.id)}" ${healthFilters.org === o.id ? "selected" : ""}>Org: ${esc(o.name)}</option>`).join("")}
</select>
<span class="text-secondary" style="font-size:13px;">${filtered.length} / ${allChecks.length} Ergebnisse</span>
<span class="text-secondary" style="font-size:13px;">
${filtered.length} / ${allChecks.length} angezeigt${totalAll > allChecks.length ? ` (von ${totalAll} insgesamt)` : ''}
</span>
</div>
</div>`;
@@ -252,6 +272,18 @@ function renderHealthDashboard() {
} else {
healthHtml += '<div class="card-body text-muted">Keine Ergebnisse mit diesen Filtern.</div>';
}
// Footer mit Mehr-laden-Buttons, falls Backend has_more meldet
if (hasMore) {
const remaining = Math.max(0, totalAll - allChecks.length);
healthHtml += `
<div class="card-body" style="display:flex;justify-content:center;gap:10px;align-items:center;border-top:1px solid var(--border, rgba(255,255,255,0.08));">
<span class="text-secondary" style="font-size:13px;">${allChecks.length} von ${totalAll} geladen</span>
<button class="btn btn-secondary btn-small" onclick="loadMoreHealth()">+200 laden</button>
<button class="btn btn-secondary btn-small" onclick="loadAllHealth()">Alle ${remaining} weiteren laden</button>
</div>`;
}
healthHtml += "</div>";
} else {
healthHtml = `