Phase 6: Verwendungs-Sicht pro Grundquelle

Backend
- /api/sources/global liefert pro Quelle articles_7d, articles_30d und
  tenant_excluded_count (eine aggregierte Query mit CTEs, kein N+1).
- Match-Logik fuer Articles: LOWER(articles.source) = LOWER(sources.name)
  - articles.source_url ist Artikel-URL, NICHT Feed-URL, daher matcht das
    nicht mit sources.url. source-Name-Match liefert sinnvolle Treffer.
- tenant_excluded_count zaehlt distinct organization_ids aus
  user_excluded_domains (per LOWER(domain)-Match).

Frontend
- dashboard.html: zwei neue sortierbare Spalten Aktivitaet (7d/30d) +
  Sperren in der Grundquellen-Tabelle.
- style.css: .activity-cell + .exclude-badge Styles (mit zero-Variante
  fuer ruhigen Look bei keiner Aktivitaet/Sperre).
- sources.js:
  - cols 9 -> 11
  - Render: 7d-Wert fett, 30d-Wert dezent, Tooltip 7 Tage / 30 Tage
  - Sort-Logik: NUMERIC_FIELDS um articles_7d/articles_30d/tenant_excluded_count
    erweitert (numerischer Compare statt localeCompare)
Dieser Commit ist enthalten in:
claude-dev
2026-05-09 03:23:42 +00:00
Ursprung 6b70a7195e
Commit e9ff2bac02
4 geänderte Dateien mit 77 neuen und 21 gelöschten Zeilen

Datei anzeigen

@@ -72,26 +72,48 @@ async def list_global_sources(
pro Zeile zeigen, ohne separate Health-Tab-Abfrage.
"""
cursor = await db.execute("""
WITH article_stats AS (
-- Match per source-Name (case-insensitive). source_url im articles ist die
-- Artikel-URL, nicht die Feed-URL - daher matcht das nicht mit sources.url.
SELECT LOWER(source) AS s_lower,
SUM(CASE WHEN collected_at > datetime('now', '-7 days') THEN 1 ELSE 0 END) AS a7d,
SUM(CASE WHEN collected_at > datetime('now', '-30 days') THEN 1 ELSE 0 END) AS a30d
FROM articles
WHERE collected_at > datetime('now', '-30 days')
AND source IS NOT NULL
GROUP BY LOWER(source)
),
excluded_counts AS (
SELECT LOWER(ued.domain) AS dom,
COUNT(DISTINCT u.organization_id) AS cnt
FROM user_excluded_domains ued
JOIN users u ON u.id = ued.user_id
WHERE ued.domain IS NOT NULL
GROUP BY LOWER(ued.domain)
),
health_agg AS (
SELECT source_id,
MAX(CASE WHEN status = 'error' THEN 3
WHEN status = 'warning' THEN 2
WHEN status = 'ok' THEN 1
ELSE 0 END) AS rank
FROM source_health_checks
GROUP BY source_id
)
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
CASE ha.rank
WHEN 3 THEN 'error'
WHEN 2 THEN 'warning'
WHEN 1 THEN 'ok'
ELSE NULL
END AS health_status,
COALESCE(ast.a7d, 0) AS articles_7d,
COALESCE(ast.a30d, 0) AS articles_30d,
COALESCE(ec.cnt, 0) AS tenant_excluded_count
FROM sources s
LEFT JOIN article_stats ast ON ast.s_lower = LOWER(s.name)
LEFT JOIN excluded_counts ec ON ec.dom = LOWER(s.domain)
LEFT JOIN health_agg ha ON ha.source_id = s.id
WHERE s.tenant_id IS NULL
ORDER BY s.category, s.source_type, s.name
""")

Datei anzeigen

@@ -934,3 +934,31 @@ input[type="date"].filter-select { padding: 6px 10px; }
max-height: 240px;
}
/* === Verwendungs-Sicht (Phase 6) === */
.activity-cell {
font-variant-numeric: tabular-nums;
color: #94a3b8;
font-size: 12px;
}
.activity-cell strong {
color: #e2e8f0;
font-weight: 600;
}
.activity-cell.activity-zero {
color: #475569;
}
.exclude-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.exclude-badge.exclude-zero {
background: transparent;
color: #475569;
font-weight: 400;
}

Datei anzeigen

@@ -328,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="articles_30d" onclick="sortGlobalSources('articles_30d')">Aktivität <span class="sort-icon"></span></th>
<th class="sortable" data-sort="tenant_excluded_count" onclick="sortGlobalSources('tenant_excluded_count')">Sperren <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>

Datei anzeigen

@@ -138,7 +138,7 @@ function renderGlobalStats(stats) {
function renderGlobalSources(sources) {
const tbody = document.getElementById("globalSourceTable");
const cols = 9;
const cols = 11;
if (sources.length === 0) {
tbody.innerHTML = `<tr><td colspan="${cols}" class="text-muted">Keine Grundquellen</td></tr>`;
return;
@@ -176,6 +176,8 @@ function renderGlobalSources(sources) {
<td>${esc(s.domain || "-")}</td>
<td>${typeLabel(s.source_type)}</td>
<td class="text-right">${s.article_count || 0}</td>
<td class="${(s.articles_30d || 0) === 0 ? "activity-cell activity-zero" : "activity-cell"}" title="7 Tage / 30 Tage"><strong>${s.articles_7d || 0}</strong> / ${s.articles_30d || 0}</td>
<td class="text-right"><span class="${(s.tenant_excluded_count || 0) === 0 ? "exclude-badge exclude-zero" : "exclude-badge"}">${s.tenant_excluded_count || 0}</span></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>
@@ -223,8 +225,10 @@ function filterGlobalSources() {
filtered.sort((a, b) => {
let va = a[globalSortField] ?? "";
let vb = b[globalSortField] ?? "";
if (globalSortField === "article_count") {
va = va || 0; vb = vb || 0;
const NUMERIC_FIELDS = ["article_count", "articles_7d", "articles_30d", "tenant_excluded_count"];
if (NUMERIC_FIELDS.includes(globalSortField)) {
va = parseInt(va) || 0;
vb = parseInt(vb) || 0;
return globalSortAsc ? va - vb : vb - va;
}
va = String(va).toLowerCase();