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:
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user