Statistik-Seite: Kennzahlen, Reichweite, ASN und Verlaeufe

Neue Seite /stats.html: Basiszahlen, Reichweite, Netze nach Betreiber
(ASN), Aufrufe pro Tag und beliebteste Seiten. Diagramme als CSS-Balken
(Breite per CSSOM, kein Inline-style) statt Chart-Library -- bleibt
CSP-konform. Verlinkt in der Legal-Nav aller Seiten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 23:21:17 +02:00
parent 662285c8b5
commit 8be8ecedc9
6 changed files with 213 additions and 3 deletions
+54
View File
@@ -299,3 +299,57 @@ hr {
border: none; border: none;
border-top: 1px solid #ccc; border-top: 1px solid #ccc;
} }
/* --- Statistik-Seite --- */
.statgrid {
display: flex;
flex-wrap: wrap;
gap: 0.6rem 1.8rem;
padding: 0.3rem 0.5rem 0.6rem;
}
.statgrid > div {
display: flex;
flex-direction: column;
}
.statgrid .num {
font-size: 1.8rem;
line-height: 1.1;
}
/* Horizontale Balken (Aufrufe/Tag, beliebteste Seiten). Breite wird per JS
gesetzt (CSSOM, kein Inline-style -> CSP-konform). */
.bars {
padding: 0.3rem 0.5rem 0.6rem;
}
.bar-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.15rem 0;
}
.bar-label {
flex: 0 0 7rem;
text-align: right;
font-size: 0.95rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bar-track {
flex: 1;
background: #eee;
}
.bar-fill {
height: 1.1rem;
background: gray;
}
.bar-value {
flex: 0 0 auto;
font-size: 0.95rem;
}
/* ASN-Namen sind länger als Datum/Pfad -> mehr Platz, Umbruch statt Ellipse. */
#stat-asns .bar-label {
flex-basis: 13rem;
white-space: normal;
}
+2 -1
View File
@@ -11,7 +11,8 @@
<nav id="legal"> <nav id="legal">
<a href="/static/docs/impressum.html">Impressum</a> - <a href="/static/docs/impressum.html">Impressum</a> -
<a href="/static/docs/datenschutz.html">Datenschutz</a> - <a href="/static/docs/datenschutz.html">Datenschutz</a> -
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> <a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
<a href="/stats.html">Statistik</a>
</nav> </nav>
<a href="/"><h1>Kontrollverlust</h1></a> <a href="/"><h1>Kontrollverlust</h1></a>
<div id="headerbar"><a class="button" href="/">zurück zum Feed</a></div> <div id="headerbar"><a class="button" href="/">zurück zum Feed</a></div>
+2 -1
View File
@@ -11,7 +11,8 @@
<nav id="legal"> <nav id="legal">
<a href="/static/docs/impressum.html">Impressum</a> - <a href="/static/docs/impressum.html">Impressum</a> -
<a href="/static/docs/datenschutz.html">Datenschutz</a> - <a href="/static/docs/datenschutz.html">Datenschutz</a> -
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> <a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
<a href="/stats.html">Statistik</a>
</nav> </nav>
<a href="/"><h1>Kontrollverlust</h1></a> <a href="/"><h1>Kontrollverlust</h1></a>
<p id="stats" class="tagline"></p> <p id="stats" class="tagline"></p>
+102
View File
@@ -0,0 +1,102 @@
"use strict";
// Statistik-Seite: holt die Aggregate von /stats/detail und rendert Zahlen +
// einfache CSS-Balken (keine Chart-Library, CSP-konform). Balkenbreiten werden
// per CSSOM (el.style.width) gesetzt -> kein Inline-style-Attribut, daher von
// der strikten CSP nicht betroffen.
function plural(n, one, many) {
return `${n} ${n === 1 ? one : many}`;
}
// numCard baut eine Kennzahl-Kachel: große Zahl + Beschriftung darunter.
function numCard(value, label) {
const cell = document.createElement("div");
const num = document.createElement("span");
num.className = "num";
num.textContent = value;
const cap = document.createElement("span");
cap.className = "muted";
cap.textContent = label;
cell.append(num, cap);
return cell;
}
// renderBars zeichnet eine Liste {label, value} als horizontale Balken. Die
// Breite ist relativ zum größten Wert.
function renderBars(container, items, formatLabel) {
container.textContent = "";
if (items.length === 0) {
const p = document.createElement("p");
p.className = "muted";
p.textContent = "Noch keine Daten.";
container.append(p);
return;
}
const max = Math.max(...items.map((it) => it.value), 1);
for (const it of items) {
const row = document.createElement("div");
row.className = "bar-row";
const label = document.createElement("span");
label.className = "bar-label";
label.textContent = formatLabel ? formatLabel(it.label) : it.label;
const track = document.createElement("div");
track.className = "bar-track";
const fill = document.createElement("div");
fill.className = "bar-fill";
fill.style.width = `${(it.value / max) * 100}%`;
track.append(fill);
const val = document.createElement("span");
val.className = "bar-value";
val.textContent = it.value;
row.append(label, track, val);
container.append(row);
}
}
async function render() {
const s = await api.get("/stats/detail");
// --- Auf einen Blick ---
const overview = document.getElementById("stat-overview");
overview.append(
numCard(s.users ?? 0, s.users === 1 ? "Profil" : "Profile"),
numCard(s.entries ?? 0, "Beiträge"),
numCard(s.toplevel ?? 0, "davon Threads"),
numCard(s.replies ?? 0, "davon Antworten"),
numCard(s.votes?.left ?? 0, "Stimmen Links"),
numCard(s.votes?.right ?? 0, "Stimmen Rechts"),
);
// --- Reichweite ---
document.getElementById("stat-reach").append(
numCard(s.impressions_total ?? 0, "Aufrufe gesamt"),
numCard(s.visitors_total ?? 0, "Netze gesamt"),
);
// --- Netze nach Betreiber (ASN) ---
renderBars(
document.getElementById("stat-asns"),
(s.top_asns ?? []).map((a) => ({ label: a.asn, value: a.hits })),
);
// --- Aufrufe pro Tag (Backend liefert absteigend -> für die Achse drehen) ---
const daily = (s.daily ?? []).slice().reverse();
renderBars(
document.getElementById("stat-daily"),
daily.map((d) => ({ label: d.day, value: d.impressions })),
(day) => day.slice(5), // "MM-DD" reicht auf der Achse
);
// --- Beliebteste Seiten ---
renderBars(
document.getElementById("stat-pages"),
(s.top_pages ?? []).map((p) => ({ label: p.path, value: p.hits })),
);
}
render();
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Statistik Kontrollverlust</title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<header>
<nav id="legal">
<a href="/static/docs/impressum.html">Impressum</a> -
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a>
</nav>
<a href="/"><h1>Kontrollverlust</h1></a>
<div id="headerbar"><a class="button" href="/">zurück zum Feed</a></div>
</header>
<main>
<section>
<h2>Statistik</h2>
<h3>Auf einen Blick</h3>
<div id="stat-overview" class="statgrid"></div>
<h3>Reichweite</h3>
<p class="muted">
Seitenaufrufe werden mit anonymisierten Netzen gezählt (IPv4 /16, IPv6 /32).
„Netze" ist eine grobe Annäherung an Besucher, keine Personenzählung.
</p>
<div id="stat-reach" class="statgrid"></div>
<h3>Netze nach Betreiber</h3>
<p class="muted">
Aufrufe je autonomem System (Netzbetreiber), aufgelöst über GeoLite2-ASN.
</p>
<div id="stat-asns" class="bars"></div>
<h3>Aufrufe pro Tag</h3>
<div id="stat-daily" class="bars"></div>
<h3>Beliebteste Seiten</h3>
<div id="stat-pages" class="bars"></div>
</section>
</main>
<script src="/js/feed.js"></script>
<script src="/js/stats.js"></script>
</body>
</html>
+2 -1
View File
@@ -11,7 +11,8 @@
<nav id="legal"> <nav id="legal">
<a href="/static/docs/impressum.html">Impressum</a> - <a href="/static/docs/impressum.html">Impressum</a> -
<a href="/static/docs/datenschutz.html">Datenschutz</a> - <a href="/static/docs/datenschutz.html">Datenschutz</a> -
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> <a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
<a href="/stats.html">Statistik</a>
</nav> </nav>
<a href="/"><h1>Kontrollverlust</h1></a> <a href="/"><h1>Kontrollverlust</h1></a>
<div id="headerbar"><a class="button" href="/">zurück zum Feed</a></div> <div id="headerbar"><a class="button" href="/">zurück zum Feed</a></div>