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