Phase 4: Admin-Übersicht erweitern (Stats-Bar + Health-Badge inline + Letzter Treffer)

Backend
- routers/sources.py:
  - GET /api/sources/global/stats NEU: aggregierte Counter
    nach Typ, Total-Articles, Health-Bilanz (errors/warnings/ok)
  - GET /api/sources/global liefert pro Quelle health_status
    (worst-case error > warning > ok, NULL wenn nie gecheckt)

Frontend
- dashboard.html sub-global-sources: Stats-Bar Container oben.
  Tabellenkopf bekommt zwei neue Spalten: Letzter Treffer + Health.
- style.css: .sources-stats-bar (analog Monitor-Style),
  .health-badge mit Varianten error/warning/ok/unknown.
- sources.js:
  - loadGlobalSources lädt parallel /global + /global/stats
  - renderGlobalStats: rendert Stats-Bar mit Total-Quellen,
    Counts pro Typ (aus META), Total-Articles, Health-Counters
  - renderGlobalSources: 9 Spalten statt 7, Letzter-Treffer + Health-Badge,
    typeLabel statt TYPE_LABELS-Direktzugriff
Dieser Commit ist enthalten in:
claude-dev
2026-05-09 03:12:30 +00:00
Ursprung 9350e4538a
Commit 2001815e19
4 geänderte Dateien mit 166 neuen und 7 gelöschten Zeilen

Datei anzeigen

@@ -65,10 +65,36 @@ async def list_global_sources(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle Grundquellen auflisten (tenant_id IS NULL)."""
cursor = await db.execute(
"SELECT * FROM sources WHERE tenant_id IS NULL ORDER BY category, source_type, name"
)
"""Alle Grundquellen auflisten (tenant_id IS NULL).
Liefert pro Quelle den worst-case Health-Status aus source_health_checks
(error > warning > ok > unknown). Damit kann das Frontend ein Inline-Badge
pro Zeile zeigen, ohne separate Health-Tab-Abfrage.
"""
cursor = await db.execute("""
SELECT s.*,
COALESCE((
SELECT CASE
WHEN MAX(CASE WHEN h.status = 'error' THEN 3
WHEN h.status = 'warning' THEN 2
WHEN h.status = 'ok' THEN 1
ELSE 0 END) = 3 THEN 'error'
WHEN MAX(CASE WHEN h.status = 'error' THEN 3
WHEN h.status = 'warning' THEN 2
WHEN h.status = 'ok' THEN 1
ELSE 0 END) = 2 THEN 'warning'
WHEN MAX(CASE WHEN h.status = 'error' THEN 3
WHEN h.status = 'warning' THEN 2
WHEN h.status = 'ok' THEN 1
ELSE 0 END) = 1 THEN 'ok'
ELSE NULL
END
FROM source_health_checks h WHERE h.source_id = s.id
), NULL) AS health_status
FROM sources s
WHERE s.tenant_id IS NULL
ORDER BY s.category, s.source_type, s.name
""")
return [dict(row) for row in await cursor.fetchall()]
@@ -174,6 +200,60 @@ async def delete_global_source(
)
@router.get("/global/stats")
async def get_global_stats(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Aggregierte Stats für die Grundquellen-Stats-Bar oben im Tab."""
cur = await db.execute("""
SELECT source_type, COUNT(*) AS count, COALESCE(SUM(article_count), 0) AS articles
FROM sources
WHERE tenant_id IS NULL AND status = 'active'
GROUP BY source_type
""")
by_type = {}
total = 0
total_articles = 0
for r in await cur.fetchall():
d = dict(r)
by_type[d["source_type"]] = {"count": d["count"], "articles": d["articles"]}
total += d["count"]
total_articles += d["articles"]
# Health-Counter
health = {"errors": 0, "warnings": 0, "ok": 0}
cur = await db.execute("""
SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks'
""")
if await cur.fetchone():
cur = await db.execute("""
SELECT h.status AS hs, COUNT(DISTINCT h.source_id) AS cnt
FROM source_health_checks h
JOIN sources s ON s.id = h.source_id
WHERE s.tenant_id IS NULL AND s.status = 'active'
GROUP BY h.status
""")
for r in await cur.fetchall():
d = dict(r)
if d["hs"] == "error":
health["errors"] = d["cnt"]
elif d["hs"] == "warning":
health["warnings"] = d["cnt"]
elif d["hs"] == "ok":
health["ok"] = d["cnt"]
return {
"by_type": by_type,
"total": total,
"total_articles": total_articles,
"health": health,
}
@router.get("/tenant")
async def list_tenant_sources(
admin: dict = Depends(get_current_admin),

Datei anzeigen

@@ -831,3 +831,44 @@ input[type="date"].filter-select { padding: 6px 10px; }
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(20px); }
}
/* === Sources Stats-Bar (Phase 4) === */
.sources-stats-bar {
display: flex;
flex-wrap: wrap;
gap: 14px;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
margin-bottom: 14px;
font-size: 13px;
}
.sources-stat-item {
display: inline-flex;
align-items: baseline;
gap: 6px;
color: #94a3b8;
}
.sources-stat-value {
color: #f0b429;
font-weight: 600;
font-size: 15px;
}
.sources-stat-item.health-error .sources-stat-value { color: #ef4444; }
.sources-stat-item.health-warning .sources-stat-value { color: #f59e0b; }
.sources-stat-item.health-ok .sources-stat-value { color: #10b981; }
/* Health-Badge inline in Tabellenzeile */
.health-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.health-badge-error { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
.health-badge-warning { background: rgba(245, 158, 11, 0.15); color: #f59e0b; }
.health-badge-ok { background: rgba(16, 185, 129, 0.15); color: #10b981; }
.health-badge-unknown { background: rgba(148, 163, 184, 0.15); color: #94a3b8; }

Datei anzeigen

@@ -298,6 +298,7 @@
<!-- Grundquellen -->
<div class="section active" id="sub-global-sources">
<div class="sources-stats-bar" id="globalStatsBar"></div>
<div class="action-bar">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<input type="text" class="search-input" id="globalSourceSearch" placeholder="Grundquelle suchen...">
@@ -327,6 +328,8 @@
<th class="sortable" data-sort="domain" onclick="sortGlobalSources('domain')">Domain <span class="sort-icon"></span></th>
<th class="sortable" data-sort="source_type" onclick="sortGlobalSources('source_type')">Typ <span class="sort-icon"></span></th>
<th class="sortable" data-sort="article_count" onclick="sortGlobalSources('article_count')">Artikel <span class="sort-icon"></span></th>
<th class="sortable" data-sort="last_seen_at" onclick="sortGlobalSources('last_seen_at')">Letzter Treffer <span class="sort-icon"></span></th>
<th class="sortable" data-sort="health_status" onclick="sortGlobalSources('health_status')">Health <span class="sort-icon"></span></th>
<th class="sortable" data-sort="status" onclick="sortGlobalSources('status')">Status <span class="sort-icon"></span></th>
<th>Aktionen</th>
</tr>

Datei anzeigen

@@ -50,16 +50,45 @@ async function loadGlobalSources() {
populateSelect(document.getElementById("globalFilterType"),
(window.META.types || []).filter(t => t.key !== "excluded"), "Alle Typen");
}
globalSourcesCache = await API.get("/api/sources/global");
const [list, stats] = await Promise.all([
API.get("/api/sources/global"),
API.get("/api/sources/global/stats"),
]);
globalSourcesCache = list;
renderGlobalStats(stats);
renderGlobalSources(globalSourcesCache);
} catch (err) {
console.error("Grundquellen laden fehlgeschlagen:", err);
}
}
function renderGlobalStats(stats) {
const bar = document.getElementById("globalStatsBar");
if (!bar) return;
if (!stats || !stats.by_type) { bar.innerHTML = ""; return; }
const types = window.META && window.META.types ? window.META.types : [];
const parts = [];
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${stats.total || 0}</span> Quellen gesamt</span>`);
for (const t of types) {
if (t.key === "excluded") continue;
const v = stats.by_type[t.key] || { count: 0, articles: 0 };
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${v.count}</span> ${esc(t.label)}</span>`);
}
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${stats.total_articles || 0}</span> Artikel</span>`);
const h = stats.health || { errors: 0, warnings: 0, ok: 0 };
if (h.errors) parts.push(`<span class="sources-stat-item health-error"><span class="sources-stat-value">${h.errors}</span> Fehler</span>`);
if (h.warnings) parts.push(`<span class="sources-stat-item health-warning"><span class="sources-stat-value">${h.warnings}</span> Warnungen</span>`);
if (h.ok) parts.push(`<span class="sources-stat-item health-ok"><span class="sources-stat-value">${h.ok}</span> OK</span>`);
bar.innerHTML = parts.join("");
}
function renderGlobalSources(sources) {
const tbody = document.getElementById("globalSourceTable");
const cols = 7;
const cols = 9;
if (sources.length === 0) {
tbody.innerHTML = `<tr><td colspan="${cols}" class="text-muted">Keine Grundquellen</td></tr>`;
return;
@@ -87,12 +116,18 @@ function renderGlobalSources(sources) {
const notesRow = hasNotes
? `<tr class="src-notes-row" id="notes-${s.id}" style="display:none;"><td colspan="${cols}" class="src-notes-cell">${esc(s.notes)}</td></tr>`
: '';
const lastSeen = s.last_seen_at ? formatDate(s.last_seen_at) : "-";
const hs = s.health_status || "unknown";
const hsLabel = { error: "Fehler", warning: "Warnung", ok: "OK", unknown: "—" }[hs];
const hsClass = "health-badge-" + (hs === "unknown" ? "unknown" : hs);
html += `<tr>
<td>${infoBtn} ${esc(s.name)}</td>
<td class="text-secondary" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.url || '')}">${esc(s.url || "-")}</td>
<td>${esc(s.domain || "-")}</td>
<td>${TYPE_LABELS[s.source_type] || s.source_type}</td>
<td>${typeLabel(s.source_type)}</td>
<td class="text-right">${s.article_count || 0}</td>
<td class="text-secondary">${lastSeen}</td>
<td><span class="health-badge ${hsClass}">${hsLabel}</span></td>
<td><span class="badge badge-${s.status === "active" ? "active" : "inactive"}">${s.status === "active" ? "Aktiv" : "Inaktiv"}</span></td>
<td>
<button class="btn btn-secondary btn-small" onclick="editGlobalSource(${s.id})">Bearbeiten</button>