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:
@@ -72,26 +72,48 @@ async def list_global_sources(
|
|||||||
pro Zeile zeigen, ohne separate Health-Tab-Abfrage.
|
pro Zeile zeigen, ohne separate Health-Tab-Abfrage.
|
||||||
"""
|
"""
|
||||||
cursor = await db.execute("""
|
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.*,
|
SELECT s.*,
|
||||||
COALESCE((
|
CASE ha.rank
|
||||||
SELECT CASE
|
WHEN 3 THEN 'error'
|
||||||
WHEN MAX(CASE WHEN h.status = 'error' THEN 3
|
WHEN 2 THEN 'warning'
|
||||||
WHEN h.status = 'warning' THEN 2
|
WHEN 1 THEN 'ok'
|
||||||
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
|
ELSE NULL
|
||||||
END
|
END AS health_status,
|
||||||
FROM source_health_checks h WHERE h.source_id = s.id
|
COALESCE(ast.a7d, 0) AS articles_7d,
|
||||||
), NULL) AS health_status
|
COALESCE(ast.a30d, 0) AS articles_30d,
|
||||||
|
COALESCE(ec.cnt, 0) AS tenant_excluded_count
|
||||||
FROM sources s
|
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
|
WHERE s.tenant_id IS NULL
|
||||||
ORDER BY s.category, s.source_type, s.name
|
ORDER BY s.category, s.source_type, s.name
|
||||||
""")
|
""")
|
||||||
|
|||||||
@@ -934,3 +934,31 @@ input[type="date"].filter-select { padding: 6px 10px; }
|
|||||||
max-height: 240px;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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="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="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="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="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>
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ function renderGlobalStats(stats) {
|
|||||||
|
|
||||||
function renderGlobalSources(sources) {
|
function renderGlobalSources(sources) {
|
||||||
const tbody = document.getElementById("globalSourceTable");
|
const tbody = document.getElementById("globalSourceTable");
|
||||||
const cols = 9;
|
const cols = 11;
|
||||||
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;
|
||||||
@@ -176,6 +176,8 @@ function renderGlobalSources(sources) {
|
|||||||
<td>${esc(s.domain || "-")}</td>
|
<td>${esc(s.domain || "-")}</td>
|
||||||
<td>${typeLabel(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="${(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 class="text-secondary">${lastSeen}</td>
|
||||||
<td><span class="health-badge ${hsClass}">${hsLabel}</span></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>
|
||||||
@@ -223,8 +225,10 @@ function filterGlobalSources() {
|
|||||||
filtered.sort((a, b) => {
|
filtered.sort((a, b) => {
|
||||||
let va = a[globalSortField] ?? "";
|
let va = a[globalSortField] ?? "";
|
||||||
let vb = b[globalSortField] ?? "";
|
let vb = b[globalSortField] ?? "";
|
||||||
if (globalSortField === "article_count") {
|
const NUMERIC_FIELDS = ["article_count", "articles_7d", "articles_30d", "tenant_excluded_count"];
|
||||||
va = va || 0; vb = vb || 0;
|
if (NUMERIC_FIELDS.includes(globalSortField)) {
|
||||||
|
va = parseInt(va) || 0;
|
||||||
|
vb = parseInt(vb) || 0;
|
||||||
return globalSortAsc ? va - vb : vb - va;
|
return globalSortAsc ? va - vb : vb - va;
|
||||||
}
|
}
|
||||||
va = String(va).toLowerCase();
|
va = String(va).toLowerCase();
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren