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:
@@ -65,10 +65,36 @@ async def list_global_sources(
|
|||||||
admin: dict = Depends(get_current_admin),
|
admin: dict = Depends(get_current_admin),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
"""Alle Grundquellen auflisten (tenant_id IS NULL)."""
|
"""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"
|
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()]
|
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")
|
@router.get("/tenant")
|
||||||
async def list_tenant_sources(
|
async def list_tenant_sources(
|
||||||
admin: dict = Depends(get_current_admin),
|
admin: dict = Depends(get_current_admin),
|
||||||
|
|||||||
@@ -831,3 +831,44 @@ input[type="date"].filter-select { padding: 6px 10px; }
|
|||||||
from { opacity: 1; transform: translateX(0); }
|
from { opacity: 1; transform: translateX(0); }
|
||||||
to { opacity: 0; transform: translateX(20px); }
|
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; }
|
||||||
|
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
|
|
||||||
<!-- Grundquellen -->
|
<!-- Grundquellen -->
|
||||||
<div class="section active" id="sub-global-sources">
|
<div class="section active" id="sub-global-sources">
|
||||||
|
<div class="sources-stats-bar" id="globalStatsBar"></div>
|
||||||
<div class="action-bar">
|
<div class="action-bar">
|
||||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||||
<input type="text" class="search-input" id="globalSourceSearch" placeholder="Grundquelle suchen...">
|
<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="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="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="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 class="sortable" data-sort="status" onclick="sortGlobalSources('status')">Status <span class="sort-icon"></span></th>
|
||||||
<th>Aktionen</th>
|
<th>Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -50,16 +50,45 @@ async function loadGlobalSources() {
|
|||||||
populateSelect(document.getElementById("globalFilterType"),
|
populateSelect(document.getElementById("globalFilterType"),
|
||||||
(window.META.types || []).filter(t => t.key !== "excluded"), "Alle Typen");
|
(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);
|
renderGlobalSources(globalSourcesCache);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Grundquellen laden fehlgeschlagen:", 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) {
|
function renderGlobalSources(sources) {
|
||||||
const tbody = document.getElementById("globalSourceTable");
|
const tbody = document.getElementById("globalSourceTable");
|
||||||
const cols = 7;
|
const cols = 9;
|
||||||
if (sources.length === 0) {
|
if (sources.length === 0) {
|
||||||
tbody.innerHTML = `<tr><td colspan="${cols}" class="text-muted">Keine Grundquellen</td></tr>`;
|
tbody.innerHTML = `<tr><td colspan="${cols}" class="text-muted">Keine Grundquellen</td></tr>`;
|
||||||
return;
|
return;
|
||||||
@@ -87,12 +116,18 @@ function renderGlobalSources(sources) {
|
|||||||
const notesRow = hasNotes
|
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>`
|
? `<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>
|
html += `<tr>
|
||||||
<td>${infoBtn} ${esc(s.name)}</td>
|
<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 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>${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-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><span class="badge badge-${s.status === "active" ? "active" : "inactive"}">${s.status === "active" ? "Aktiv" : "Inaktiv"}</span></td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-secondary btn-small" onclick="editGlobalSource(${s.id})">Bearbeiten</button>
|
<button class="btn btn-secondary btn-small" onclick="editGlobalSource(${s.id})">Bearbeiten</button>
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren