Compare commits
2
Commits
26b0182391
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07a2b64eff | ||
|
|
047ad392d9 |
@@ -0,0 +1,566 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
_ "time/tzdata" // Alpine-Image hat keine Zonendaten
|
||||||
|
)
|
||||||
|
|
||||||
|
// Diagramme der Statistik-Seite. Sie werden einmal am Tag (kurz nach
|
||||||
|
// Mitternacht, deutsche Zeit) als fertiges SVG gerendert und im Speicher
|
||||||
|
// gehalten; ein Seitenaufruf liefert nur noch die Strings aus. Das SVG nutzt
|
||||||
|
// ausschließlich Präsentationsattribute (stroke, fill ...) und kein style-
|
||||||
|
// Attribut -> inline eingebettet von der strikten CSP nicht betroffen, und die
|
||||||
|
// Schrift erbt es von der Seite.
|
||||||
|
//
|
||||||
|
// Der laufende Tag fehlt bewusst: er wäre bis zum nächsten Rendern immer nur
|
||||||
|
// ein halber Tag und würde die Kurven am rechten Rand abknicken lassen.
|
||||||
|
|
||||||
|
var berlin = mustLoadLocation("Europe/Berlin")
|
||||||
|
|
||||||
|
func mustLoadLocation(name string) *time.Location {
|
||||||
|
loc, err := time.LoadLocation(name)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatsCharts ist die Antwort von /api/stats/charts. Jedes Diagramm gibt es
|
||||||
|
// schmal (Handy, schmale Fenster) und breit (Widescreen); welches sichtbar ist,
|
||||||
|
// entscheidet das CSS. So bleibt die Schrift in beiden Fällen etwa 1:1.
|
||||||
|
type StatsCharts struct {
|
||||||
|
Generated int64 `json:"generated"`
|
||||||
|
Charts map[string]ChartPair `json:"charts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartPair struct {
|
||||||
|
Narrow string `json:"narrow"`
|
||||||
|
Wide string `json:"wide"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// both rendert ein Diagramm in beiden Größen.
|
||||||
|
func both(render func(sz size) string) ChartPair {
|
||||||
|
return ChartPair{Narrow: render(narrow), Wide: render(wide)}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
chartMu sync.RWMutex
|
||||||
|
chartCache StatsCharts
|
||||||
|
chartStop = make(chan struct{})
|
||||||
|
chartDone = make(chan struct{})
|
||||||
|
)
|
||||||
|
|
||||||
|
// startChartWorker rendert sofort und danach jede Nacht neu. Schlägt das
|
||||||
|
// Rendern fehl, bleibt der alte Stand stehen und es gibt nach 15 Minuten einen
|
||||||
|
// neuen Versuch.
|
||||||
|
func startChartWorker() {
|
||||||
|
go func() {
|
||||||
|
defer close(chartDone)
|
||||||
|
for {
|
||||||
|
wait := 15 * time.Minute
|
||||||
|
if err := renderCharts(time.Now()); err != nil {
|
||||||
|
log.Printf("statistik-diagramme: %v", err)
|
||||||
|
} else {
|
||||||
|
wait = time.Until(nextChartRun(time.Now()))
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-chartStop:
|
||||||
|
return
|
||||||
|
case <-time.After(wait):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopChartWorker wartet ein laufendes Rendern ab (vor db.Close rufen).
|
||||||
|
func stopChartWorker() {
|
||||||
|
close(chartStop)
|
||||||
|
<-chartDone
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextChartRun ist eine Minute nach der nächsten Mitternacht in Berlin.
|
||||||
|
func nextChartRun(now time.Time) time.Time {
|
||||||
|
y, m, d := now.In(berlin).Date()
|
||||||
|
return time.Date(y, m, d+1, 0, 1, 0, 0, berlin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderCharts(now time.Time) error {
|
||||||
|
impr, err := collectImpressionStats(now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ent, err := collectEntryStats(now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
set := StatsCharts{
|
||||||
|
Generated: now.Unix(),
|
||||||
|
Charts: map[string]ChartPair{
|
||||||
|
"impressions": both(func(sz size) string {
|
||||||
|
return lineChart(sz, "Aufrufe pro Tag", impr.days, impr.hits, "Aufrufe")
|
||||||
|
}),
|
||||||
|
"networks": both(func(sz size) string {
|
||||||
|
return lineChart(sz, "Netze pro Tag", impr.days, impr.networks, "Netze")
|
||||||
|
}),
|
||||||
|
"entries": both(func(sz size) string {
|
||||||
|
return lineChart(sz, "Beiträge pro Tag", ent.days, ent.perDay, "Beiträge")
|
||||||
|
}),
|
||||||
|
"weekday": both(func(sz size) string {
|
||||||
|
return stackedBars(sz, "Beiträge pro Wochentag", weekdayNames[:], weekdayLong[:], ent.weekday)
|
||||||
|
}),
|
||||||
|
"hour": both(func(sz size) string {
|
||||||
|
return stackedBars(sz, "Beiträge pro Tageszeit", hourLabels(), hourRanges(), ent.hour)
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
chartMu.Lock()
|
||||||
|
chartCache = set
|
||||||
|
chartMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStatsCharts liefert die fertigen SVGs. Ist noch nichts gerendert
|
||||||
|
// (Start läuft noch, Tests ohne Worker), wird einmalig direkt gerendert.
|
||||||
|
func handleStatsCharts(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
chartMu.RLock()
|
||||||
|
set := chartCache
|
||||||
|
chartMu.RUnlock()
|
||||||
|
if set.Generated == 0 {
|
||||||
|
if err := renderCharts(time.Now()); err != nil {
|
||||||
|
return Internal(err)
|
||||||
|
}
|
||||||
|
chartMu.RLock()
|
||||||
|
set = chartCache
|
||||||
|
chartMu.RUnlock()
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=600")
|
||||||
|
writeJSON(w, http.StatusOK, set)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Daten ---
|
||||||
|
|
||||||
|
type impressionStats struct {
|
||||||
|
days []time.Time // lückenlos, ohne heute
|
||||||
|
hits, networks []float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectImpressionStats summiert Aufrufe und verschiedene Netze je Tag. Die
|
||||||
|
// Impression-Tage sind UTC-Daten (siehe recordImpression).
|
||||||
|
func collectImpressionStats(now time.Time) (impressionStats, error) {
|
||||||
|
var s impressionStats
|
||||||
|
today := civilDate(now.UTC())
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT day, SUM(hits)::bigint, COUNT(DISTINCT anon_ip)
|
||||||
|
FROM impression
|
||||||
|
WHERE day < $1
|
||||||
|
GROUP BY day`, today.Format(time.DateOnly))
|
||||||
|
if err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
type counts struct{ hits, networks int64 }
|
||||||
|
byDay := map[time.Time]counts{}
|
||||||
|
var first time.Time
|
||||||
|
for rows.Next() {
|
||||||
|
var day string
|
||||||
|
var c counts
|
||||||
|
if err := rows.Scan(&day, &c.hits, &c.networks); err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
d, err := time.Parse(time.DateOnly, day)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byDay[d] = c
|
||||||
|
if first.IsZero() || d.Before(first) {
|
||||||
|
first = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
if first.IsZero() {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
s.days = dayRange(first, today)
|
||||||
|
for _, d := range s.days {
|
||||||
|
c := byDay[d]
|
||||||
|
s.hits = append(s.hits, float64(c.hits))
|
||||||
|
s.networks = append(s.networks, float64(c.networks))
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reihenfolge der Stapel: Threads unten, Antworten darüber.
|
||||||
|
var postKinds = [2]string{"Threads", "Antworten"}
|
||||||
|
|
||||||
|
type entryStats struct {
|
||||||
|
days []time.Time // erster Beitragstag bis gestern (deutsche Zeit)
|
||||||
|
perDay []float64
|
||||||
|
weekday [][2]float64 // Mo..So, Ø Beiträge je solchem Tag
|
||||||
|
hour [][2]float64 // 0..23 Uhr, Ø Beiträge je Tag
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectEntryStats zählt alle Beiträge einschließlich gelöschter (created_at
|
||||||
|
// bleibt beim Löschen erhalten). Tage, Wochentage und Stunden gelten in
|
||||||
|
// deutscher Zeit. Wochentag und Stunde sind Durchschnitte: Summe geteilt durch
|
||||||
|
// die Zahl der Montage, Dienstage ... bzw. aller Tage im Zeitraum.
|
||||||
|
func collectEntryStats(now time.Time) (entryStats, error) {
|
||||||
|
var s entryStats
|
||||||
|
today := civilDate(now.In(berlin))
|
||||||
|
midnight := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, berlin)
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT created_at, reply_to <> 0 FROM entry WHERE created_at > 0 AND created_at < $1`,
|
||||||
|
midnight.Unix())
|
||||||
|
if err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
perDay := map[time.Time]float64{}
|
||||||
|
var weekdaySum [7][2]float64
|
||||||
|
var hourSum [24][2]float64
|
||||||
|
var first time.Time
|
||||||
|
for rows.Next() {
|
||||||
|
var created int64
|
||||||
|
var reply bool
|
||||||
|
if err := rows.Scan(&created, &reply); err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
t := time.Unix(created, 0).In(berlin)
|
||||||
|
d := civilDate(t)
|
||||||
|
kind := 0
|
||||||
|
if reply {
|
||||||
|
kind = 1
|
||||||
|
}
|
||||||
|
perDay[d]++
|
||||||
|
weekdaySum[isoWeekday(d)][kind]++
|
||||||
|
hourSum[t.Hour()][kind]++
|
||||||
|
if first.IsZero() || d.Before(first) {
|
||||||
|
first = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
s.weekday = make([][2]float64, 7)
|
||||||
|
s.hour = make([][2]float64, 24)
|
||||||
|
if first.IsZero() {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
s.days = dayRange(first, today)
|
||||||
|
var weekdayCount [7]float64
|
||||||
|
for _, d := range s.days {
|
||||||
|
s.perDay = append(s.perDay, perDay[d])
|
||||||
|
weekdayCount[isoWeekday(d)]++
|
||||||
|
}
|
||||||
|
for i := range s.weekday {
|
||||||
|
for k := range postKinds {
|
||||||
|
if weekdayCount[i] > 0 {
|
||||||
|
s.weekday[i][k] = weekdaySum[i][k] / weekdayCount[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := float64(len(s.days))
|
||||||
|
for i := range s.hour {
|
||||||
|
for k := range postKinds {
|
||||||
|
s.hour[i][k] = hourSum[i][k] / n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// civilDate ist das Kalenderdatum von t (in dessen Zone) als UTC-Mitternacht.
|
||||||
|
// Gerechnet wird mit diesen Werten, damit Sommerzeitwechsel keine 23- oder
|
||||||
|
// 25-Stunden-Tage in die Tagesfolge bringen.
|
||||||
|
func civilDate(t time.Time) time.Time {
|
||||||
|
y, m, d := t.Date()
|
||||||
|
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dayRange liefert alle Tage von first bis ausschließlich end.
|
||||||
|
func dayRange(first, end time.Time) []time.Time {
|
||||||
|
var days []time.Time
|
||||||
|
for d := first; d.Before(end); d = d.AddDate(0, 0, 1) {
|
||||||
|
days = append(days, d)
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
// isoWeekday: Montag = 0 ... Sonntag = 6.
|
||||||
|
func isoWeekday(d time.Time) int {
|
||||||
|
return (int(d.Weekday()) + 6) % 7
|
||||||
|
}
|
||||||
|
|
||||||
|
var weekdayNames = [7]string{"Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"}
|
||||||
|
|
||||||
|
var weekdayLong = [7]string{"Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"}
|
||||||
|
|
||||||
|
// hourRanges: "9–10 Uhr" für die Infozeile.
|
||||||
|
func hourRanges() []string {
|
||||||
|
l := make([]string, 24)
|
||||||
|
for i := range l {
|
||||||
|
l[i] = fmt.Sprintf("%d–%d Uhr", i, i+1)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func hourLabels() []string {
|
||||||
|
l := make([]string, 24)
|
||||||
|
for i := range l {
|
||||||
|
l[i] = strconv.Itoa(i)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SVG ---
|
||||||
|
|
||||||
|
// Maße im viewBox-Raum. narrow entspricht der schmalen Inhaltsspalte (600 px),
|
||||||
|
// wide der breiten Statistik-Seite (1200 px); bis dorthin wird 1:1 gezeichnet,
|
||||||
|
// darunter skaliert das SVG mit.
|
||||||
|
type size struct{ w, h int }
|
||||||
|
|
||||||
|
var (
|
||||||
|
narrow = size{600, 240}
|
||||||
|
wide = size{1200, 300}
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
padLeft = 44
|
||||||
|
padRight = 12
|
||||||
|
padTop = 12
|
||||||
|
padBottom = 26
|
||||||
|
fontSize = 13
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
colorGrid = "#ddd"
|
||||||
|
colorStack = [2]string{"#444", "#aaa"}
|
||||||
|
)
|
||||||
|
|
||||||
|
type svg struct{ strings.Builder }
|
||||||
|
|
||||||
|
func (b *svg) f(format string, a ...any) { fmt.Fprintf(b, format, a...) }
|
||||||
|
|
||||||
|
func svgOpen(b *svg, sz size, label string) {
|
||||||
|
b.f(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" role="img" aria-label="%s" font-size="%d" fill="black">`,
|
||||||
|
sz.w, sz.h, html.EscapeString(label), fontSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func emptyChart(sz size, label string) string {
|
||||||
|
var b svg
|
||||||
|
svgOpen(&b, sz, label)
|
||||||
|
b.f(`<text x="%d" y="%d" text-anchor="middle" fill="gray">Noch keine Daten.</text></svg>`, sz.w/2, sz.h/2)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// niceScale wählt eine runde Schrittweite (1, 2, 5 × 10^k) für etwa vier
|
||||||
|
// Gitterlinien und die darauf aufgerundete Obergrenze.
|
||||||
|
func niceScale(max float64) (step, top float64) {
|
||||||
|
if max <= 0 {
|
||||||
|
max = 1
|
||||||
|
}
|
||||||
|
raw := max / 4
|
||||||
|
mag := math.Pow(10, math.Floor(math.Log10(raw)))
|
||||||
|
step = 10 * mag
|
||||||
|
for _, m := range []float64{1, 2, 5} {
|
||||||
|
if m*mag >= raw {
|
||||||
|
step = m * mag
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return step, math.Ceil(max/step-1e-9) * step
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmtNum schreibt eine Zahl mit höchstens zwei Nachkommastellen und
|
||||||
|
// deutschem Komma.
|
||||||
|
func fmtNum(v float64) string {
|
||||||
|
return strings.Replace(strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64), ".", ",", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// yAxis zeichnet Gitterlinien mit Beschriftung und liefert die Abbildung
|
||||||
|
// Wert -> y-Koordinate.
|
||||||
|
func yAxis(b *svg, sz size, max float64, top int) func(float64) float64 {
|
||||||
|
step, maxV := niceScale(max)
|
||||||
|
bottom := float64(sz.h - padBottom)
|
||||||
|
y := func(v float64) float64 { return bottom - v/maxV*(bottom-float64(top)) }
|
||||||
|
for v := 0.0; v <= maxV+step/2; v += step {
|
||||||
|
yy := y(v)
|
||||||
|
color := colorGrid
|
||||||
|
if v == 0 {
|
||||||
|
color = "black"
|
||||||
|
}
|
||||||
|
b.f(`<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="%s"/>`, padLeft, yy, sz.w-padRight, yy, color)
|
||||||
|
b.f(`<text x="%d" y="%.1f" text-anchor="end">%s</text>`, padLeft-6, yy+4, fmtNum(v))
|
||||||
|
}
|
||||||
|
return y
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxOf(vals []float64) float64 {
|
||||||
|
m := 0.0
|
||||||
|
for _, v := range vals {
|
||||||
|
m = math.Max(m, v)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// lineChart zeichnet eine Tagesreihe als Linie. Alle Tage seit Beginn werden
|
||||||
|
// auf die volle Breite gestaucht; unsichtbare Streifen je Tag tragen den
|
||||||
|
// Tooltip (<title>), denselben Text in data-tip für die Infozeile unter dem
|
||||||
|
// Diagramm (StatsPage.tsx) und eine senkrechte Markierung.
|
||||||
|
func lineChart(sz size, label string, days []time.Time, vals []float64, unit string) string {
|
||||||
|
if len(days) == 0 {
|
||||||
|
return emptyChart(sz, label)
|
||||||
|
}
|
||||||
|
var b svg
|
||||||
|
svgOpen(&b, sz, label)
|
||||||
|
y := yAxis(&b, sz, maxOf(vals), padTop)
|
||||||
|
plotW := float64(sz.w - padLeft - padRight)
|
||||||
|
n := len(days)
|
||||||
|
x := func(i int) float64 {
|
||||||
|
if n == 1 {
|
||||||
|
return padLeft + plotW/2
|
||||||
|
}
|
||||||
|
return padLeft + float64(i)*plotW/float64(n-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
bottom := sz.h - padBottom
|
||||||
|
for _, i := range dateTicks(days) {
|
||||||
|
xx := x(i)
|
||||||
|
b.f(`<line x1="%.1f" y1="%d" x2="%.1f" y2="%d" stroke="black"/>`, xx, bottom, xx, bottom+4)
|
||||||
|
tx := math.Min(math.Max(xx, padLeft+16), float64(sz.w-padRight-16))
|
||||||
|
b.f(`<text x="%.1f" y="%d" text-anchor="middle">%s</text>`, tx, bottom+18, dateTickLabel(days[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
var pts strings.Builder
|
||||||
|
for i, v := range vals {
|
||||||
|
fmt.Fprintf(&pts, "%.1f,%.1f ", x(i), y(v))
|
||||||
|
}
|
||||||
|
if n == 1 {
|
||||||
|
b.f(`<circle cx="%.1f" cy="%.1f" r="3"/>`, x(0), y(vals[0]))
|
||||||
|
} else {
|
||||||
|
b.f(`<polyline points="%s" fill="none" stroke="black" stroke-width="1.5" stroke-linejoin="round"/>`,
|
||||||
|
strings.TrimSpace(pts.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
slot := plotW / float64(max(n-1, 1))
|
||||||
|
for i, d := range days {
|
||||||
|
tip := fmt.Sprintf("%s, %s: %s %s", weekdayNames[isoWeekday(d)], d.Format("02.01.2006"), fmtNum(vals[i]), unit)
|
||||||
|
// Die Markierungslinie ist versteckt; das CSS zeigt sie beim Hovern
|
||||||
|
// über den Streifen (.chart g:hover .mark).
|
||||||
|
b.f(`<g data-tip="%s"><title>%s</title><rect x="%.1f" y="%d" width="%.1f" height="%d" fill="transparent"/>`,
|
||||||
|
tip, tip, x(i)-slot/2, padTop, slot, bottom-padTop)
|
||||||
|
b.f(`<line class="mark" x1="%.1f" y1="%d" x2="%.1f" y2="%d" stroke="black" visibility="hidden" pointer-events="none"/></g>`,
|
||||||
|
x(i), padTop, x(i), bottom)
|
||||||
|
}
|
||||||
|
b.f(`</svg>`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
var monthNames = [12]string{"Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"}
|
||||||
|
|
||||||
|
// dateTicks wählt die beschrifteten Tage: Monatsanfänge, bei langen Reihen
|
||||||
|
// nur jedes Quartal, Halbjahr oder Jahr; bei Reihen unter zwei Monaten die
|
||||||
|
// Montage. Höchstens acht Beschriftungen.
|
||||||
|
func dateTicks(days []time.Time) []int {
|
||||||
|
var months []int
|
||||||
|
for i, d := range days {
|
||||||
|
if d.Day() == 1 {
|
||||||
|
months = append(months, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(months) >= 2 {
|
||||||
|
for _, step := range []int{1, 3, 6, 12} {
|
||||||
|
var picked []int
|
||||||
|
for _, i := range months {
|
||||||
|
if (int(days[i].Month())-1)%step == 0 {
|
||||||
|
picked = append(picked, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(picked) <= 8 || step == 12 {
|
||||||
|
return picked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var mondays []int
|
||||||
|
for i, d := range days {
|
||||||
|
if d.Weekday() == time.Monday {
|
||||||
|
mondays = append(mondays, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for len(mondays) > 8 {
|
||||||
|
var half []int
|
||||||
|
for j := 0; j < len(mondays); j += 2 {
|
||||||
|
half = append(half, mondays[j])
|
||||||
|
}
|
||||||
|
mondays = half
|
||||||
|
}
|
||||||
|
return mondays
|
||||||
|
}
|
||||||
|
|
||||||
|
// dateTickLabel: "02.10." bei Wochen-Ticks, sonst Monatsname; der Januar
|
||||||
|
// zeigt stattdessen die Jahreszahl.
|
||||||
|
func dateTickLabel(d time.Time) string {
|
||||||
|
if d.Day() != 1 {
|
||||||
|
return d.Format("02.01.")
|
||||||
|
}
|
||||||
|
if d.Month() == time.January {
|
||||||
|
return strconv.Itoa(d.Year())
|
||||||
|
}
|
||||||
|
return monthNames[d.Month()-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// stackedBars zeichnet Ø Threads (unten) und Antworten (oben) je Kategorie
|
||||||
|
// als gestapelte Säulen mit Legende.
|
||||||
|
func stackedBars(sz size, label string, labels, tipLabels []string, vals [][2]float64) string {
|
||||||
|
totals := make([]float64, len(vals))
|
||||||
|
for i, v := range vals {
|
||||||
|
totals[i] = v[0] + v[1]
|
||||||
|
}
|
||||||
|
if maxOf(totals) == 0 {
|
||||||
|
return emptyChart(sz, label)
|
||||||
|
}
|
||||||
|
var b svg
|
||||||
|
svgOpen(&b, sz, label)
|
||||||
|
top := padTop + 20
|
||||||
|
x0 := padLeft + 4
|
||||||
|
for k, name := range postKinds {
|
||||||
|
b.f(`<rect x="%d" y="%d" width="11" height="11" fill="%s"/>`, x0, padTop-2, colorStack[k])
|
||||||
|
b.f(`<text x="%d" y="%d">%s</text>`, x0+16, padTop+8, name)
|
||||||
|
x0 += 16 + 9*len(name) + 16
|
||||||
|
}
|
||||||
|
y := yAxis(&b, sz, maxOf(totals), top)
|
||||||
|
|
||||||
|
bottom := float64(sz.h - padBottom)
|
||||||
|
slot := float64(sz.w-padLeft-padRight) / float64(len(vals))
|
||||||
|
barW := slot * 0.7
|
||||||
|
for i, v := range vals {
|
||||||
|
x := padLeft + float64(i)*slot + (slot-barW)/2
|
||||||
|
tip := html.EscapeString(fmt.Sprintf("%s: Ø %s Beiträge pro Tag (%s Threads, %s Antworten)",
|
||||||
|
tipLabels[i], fmtNum(totals[i]), fmtNum(v[0]), fmtNum(v[1])))
|
||||||
|
b.f(`<g data-tip="%s"><title>%s</title>`, tip, tip)
|
||||||
|
// Unsichtbare volle Säule, damit auch kurze Balken gut zu treffen sind.
|
||||||
|
b.f(`<rect x="%.1f" y="%d" width="%.1f" height="%.1f" fill="transparent"/>`,
|
||||||
|
padLeft+float64(i)*slot, top, slot, bottom-float64(top))
|
||||||
|
base := 0.0
|
||||||
|
for k := range postKinds {
|
||||||
|
if v[k] > 0 {
|
||||||
|
b.f(`<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s"/>`,
|
||||||
|
x, y(base+v[k]), barW, y(base)-y(base+v[k]), colorStack[k])
|
||||||
|
}
|
||||||
|
base += v[k]
|
||||||
|
}
|
||||||
|
b.f(`</g>`)
|
||||||
|
b.f(`<text x="%.1f" y="%.0f" text-anchor="middle">%s</text>`, x+barW/2, bottom+18, html.EscapeString(labels[i]))
|
||||||
|
}
|
||||||
|
b.f(`</svg>`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNiceScale(t *testing.T) {
|
||||||
|
cases := []struct{ max, step, top float64 }{
|
||||||
|
{0, 0.5, 1},
|
||||||
|
{1, 0.5, 1},
|
||||||
|
{7, 2, 8},
|
||||||
|
{123, 50, 150},
|
||||||
|
{0.37, 0.1, 0.4},
|
||||||
|
{1000, 500, 1000},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
step, top := niceScale(c.max)
|
||||||
|
if math.Abs(step-c.step) > 1e-9 || math.Abs(top-c.top) > 1e-9 {
|
||||||
|
t.Errorf("niceScale(%v) = %v, %v; erwartet %v, %v", c.max, step, top, c.step, c.top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDateTicks(t *testing.T) {
|
||||||
|
day := func(s string) time.Time { d, _ := time.Parse(time.DateOnly, s); return d }
|
||||||
|
labels := func(from, to string) []string {
|
||||||
|
days := dayRange(day(from), day(to))
|
||||||
|
var l []string
|
||||||
|
for _, i := range dateTicks(days) {
|
||||||
|
l = append(l, dateTickLabel(days[i]))
|
||||||
|
}
|
||||||
|
return strings.Fields(strings.Join(l, " "))
|
||||||
|
}
|
||||||
|
if got := strings.Join(labels("2026-06-18", "2026-10-06"), " "); got != "Jul Aug Sep Okt" {
|
||||||
|
t.Errorf("Monate: %q", got)
|
||||||
|
}
|
||||||
|
if got := strings.Join(labels("2025-12-13", "2027-12-01"), " "); got != "2026 Apr Jul Okt 2027 Apr Jul Okt" {
|
||||||
|
t.Errorf("Quartale: %q", got)
|
||||||
|
}
|
||||||
|
if got := strings.Join(labels("2026-09-20", "2026-10-06"), " "); got != "21.09. 28.09. 05.10." {
|
||||||
|
t.Errorf("Wochen: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ende-zu-Ende: Beiträge und Aufrufe direkt in die DB, Aggregate prüfen,
|
||||||
|
// dann über die API ausliefern und jedes SVG als XML parsen.
|
||||||
|
func TestStatsCharts(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
at := func(s string) int64 {
|
||||||
|
tm, err := time.ParseInLocation("2006-01-02 15:04", s, berlin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tm.Unix()
|
||||||
|
}
|
||||||
|
// Mo 2026-09-28 bis Mi 2026-09-30; "heute" ist Do 2026-10-01.
|
||||||
|
posts := []struct {
|
||||||
|
created int64
|
||||||
|
reply int64
|
||||||
|
deleted int64
|
||||||
|
}{
|
||||||
|
{at("2026-09-28 09:30"), 0, 0},
|
||||||
|
{at("2026-09-28 09:45"), 1, 0},
|
||||||
|
{at("2026-09-28 23:30"), 1, 1}, // gelöscht, zählt trotzdem; UTC wäre es 21 Uhr
|
||||||
|
{at("2026-09-30 00:10"), 0, 0},
|
||||||
|
{at("2026-10-01 08:00"), 0, 0}, // heute -> fehlt
|
||||||
|
}
|
||||||
|
for i, p := range posts {
|
||||||
|
if _, err := db.Exec(`INSERT INTO entry (pid, uid, created_at, reply_to, deleted) VALUES ($1, 1, $2, $3, $4)`,
|
||||||
|
100+i, p.created, p.reply, p.deleted); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, imp := range []struct {
|
||||||
|
day, ip string
|
||||||
|
hits int
|
||||||
|
}{
|
||||||
|
{"2026-09-27", "10.1.0.0", 3},
|
||||||
|
{"2026-09-27", "10.2.0.0", 2},
|
||||||
|
{"2026-09-29", "10.1.0.0", 1},
|
||||||
|
{"2026-10-01", "10.1.0.0", 9}, // heute -> fehlt
|
||||||
|
} {
|
||||||
|
db.Exec(`INSERT INTO impression (day, anon_ip, path, hits) VALUES ($1, $2, '/', $3)`, imp.day, imp.ip, imp.hits)
|
||||||
|
}
|
||||||
|
now := time.Date(2026, 10, 1, 12, 0, 0, 0, berlin)
|
||||||
|
|
||||||
|
ent, err := collectEntryStats(now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(ent.days) != 3 || ent.perDay[0] != 3 || ent.perDay[1] != 0 || ent.perDay[2] != 1 {
|
||||||
|
t.Errorf("perDay %v über %d Tage", ent.perDay, len(ent.days))
|
||||||
|
}
|
||||||
|
// Je ein Mo/Di/Mi im Zeitraum -> Ø = Summe.
|
||||||
|
if ent.weekday[0] != [2]float64{1, 2} || ent.weekday[2] != [2]float64{1, 0} || ent.weekday[3] != [2]float64{} {
|
||||||
|
t.Errorf("weekday %v", ent.weekday)
|
||||||
|
}
|
||||||
|
// Stunden: Summe / 3 Tage.
|
||||||
|
if ent.hour[9] != [2]float64{1.0 / 3, 1.0 / 3} || ent.hour[23] != [2]float64{0, 1.0 / 3} || ent.hour[0] != [2]float64{1.0 / 3, 0} {
|
||||||
|
t.Errorf("hour 0/9/23: %v %v %v", ent.hour[0], ent.hour[9], ent.hour[23])
|
||||||
|
}
|
||||||
|
|
||||||
|
impr, err := collectImpressionStats(now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantHits, wantNets := []float64{5, 0, 1, 0}, []float64{2, 0, 1, 0}
|
||||||
|
if len(impr.days) != 4 {
|
||||||
|
t.Fatalf("impression-Tage: %v", impr.days)
|
||||||
|
}
|
||||||
|
for i := range wantHits {
|
||||||
|
if impr.hits[i] != wantHits[i] || impr.networks[i] != wantNets[i] {
|
||||||
|
t.Errorf("Tag %d: %v/%v", i, impr.hits[i], impr.networks[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := renderCharts(now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp, err := http.Get(srv.URL + "/api/stats/charts")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var set StatsCharts
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&set); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"impressions", "networks", "entries", "weekday", "hour"} {
|
||||||
|
pair := set.Charts[name]
|
||||||
|
if !strings.Contains(pair.Narrow, `viewBox="0 0 600 `) || !strings.Contains(pair.Wide, `viewBox="0 0 1200 `) {
|
||||||
|
t.Errorf("%s: falsche Größen", name)
|
||||||
|
}
|
||||||
|
for _, s := range []string{pair.Narrow, pair.Wide} {
|
||||||
|
if !strings.HasPrefix(s, "<svg") || strings.Contains(s, "style=") {
|
||||||
|
t.Errorf("%s: kein CSP-taugliches SVG", name)
|
||||||
|
}
|
||||||
|
d := xml.NewDecoder(strings.NewReader(s))
|
||||||
|
for {
|
||||||
|
if _, err := d.Token(); err == io.EOF {
|
||||||
|
break
|
||||||
|
} else if err != nil {
|
||||||
|
t.Errorf("%s: kein gültiges XML: %v", name, err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(set.Charts["weekday"].Wide, `data-tip="Montag: Ø 3 Beiträge pro Tag (1 Threads, 2 Antworten)"`) {
|
||||||
|
t.Error("Tooltip Montag fehlt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyCharts(t *testing.T) {
|
||||||
|
newTestServer(t)
|
||||||
|
if err := renderCharts(time.Now()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, pair := range chartCache.Charts {
|
||||||
|
if !strings.Contains(pair.Narrow, "Noch keine Daten.") || !strings.Contains(pair.Wide, "Noch keine Daten.") {
|
||||||
|
t.Errorf("%s ohne Daten: %+v", name, pair)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ø Beiträge/Tag: nur die letzten 30 vollen Tage, gelöschte zählen, heute nicht.
|
||||||
|
func TestStatsDetailAverage(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
now := time.Now().In(berlin)
|
||||||
|
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, berlin)
|
||||||
|
for i, created := range []int64{
|
||||||
|
midnight.Add(-time.Hour).Unix(), // gestern
|
||||||
|
midnight.AddDate(0, 0, -30).Unix(), // erster Tag des Fensters
|
||||||
|
midnight.AddDate(0, 0, -30).Unix() - 1, // davor
|
||||||
|
midnight.Add(time.Minute).Unix(), // heute
|
||||||
|
midnight.AddDate(0, 0, -10).Add(time.Hour).Unix(), // gelöscht
|
||||||
|
} {
|
||||||
|
db.Exec(`INSERT INTO entry (pid, uid, created_at, deleted) VALUES ($1, 1, $2, $3)`, 200+i, created, i/4)
|
||||||
|
}
|
||||||
|
resp, err := http.Get(srv.URL + "/api/stats/detail")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var body struct {
|
||||||
|
Avg float64 `json:"entries_per_day_30"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
if math.Abs(body.Avg-3.0/30) > 1e-9 {
|
||||||
|
t.Errorf("entries_per_day_30 = %v, erwartet 0,1", body.Avg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@ function plural(n: number, one: string, many: string) {
|
|||||||
// Layout ist der gemeinsame Seitenrahmen: die Startseite hat Tagline und
|
// Layout ist der gemeinsame Seitenrahmen: die Startseite hat Tagline und
|
||||||
// Headerbar, alle anderen Seiten den "zurück zum Feed"-Knopf.
|
// Headerbar, alle anderen Seiten den "zurück zum Feed"-Knopf.
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const isHome = useLocation().pathname === "/";
|
const path = useLocation().pathname;
|
||||||
|
const isHome = path === "/";
|
||||||
const days = Math.floor((Date.now() - dataLossStart) / 86400000);
|
const days = Math.floor((Date.now() - dataLossStart) / 86400000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -38,7 +39,8 @@ export function Layout() {
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
{/* Die Statistik-Seite darf auf breiten Bildschirmen breiter werden. */}
|
||||||
|
<main className={path === "/stats" ? "wide" : undefined}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+29
-32
@@ -117,6 +117,10 @@ main {
|
|||||||
margin: auto;
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main.wide {
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
main > section,
|
main > section,
|
||||||
main > div,
|
main > div,
|
||||||
main > h2,
|
main > h2,
|
||||||
@@ -417,41 +421,34 @@ form input[type="file"] {
|
|||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Horizontale Balken (Aufrufe/Tag, Betreiber). Breite setzt React per CSSOM
|
/* Server-SVGs (charts.go): schmal 600, breit 1200 im viewBox-Raum. Ab 900 px
|
||||||
(kein Inline-style im HTML -> CSP-konform). */
|
Fensterbreite die breite Fassung, damit die Schrift nicht zu groß wird. */
|
||||||
.bars {
|
.chart {
|
||||||
padding: 0.3rem 0.5rem 0.6rem;
|
padding: 0.3rem 0 0.6rem;
|
||||||
}
|
}
|
||||||
.bar-row {
|
.chart svg {
|
||||||
display: flex;
|
display: block;
|
||||||
align-items: center;
|
width: 100%;
|
||||||
gap: 0.5rem;
|
height: auto;
|
||||||
margin: 0.15rem 0;
|
|
||||||
}
|
}
|
||||||
.bar-label {
|
.chart-wide {
|
||||||
flex: 0 0 7rem;
|
display: none;
|
||||||
text-align: right;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.bar-track {
|
/* Senkrechte Markierung des Tages unter dem Zeiger (Liniendiagramme). */
|
||||||
flex: 1;
|
.chart g:hover .mark {
|
||||||
background: #eee;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
.bar-fill {
|
/* Infozeile unter dem Diagramm: Höhe immer reserviert. */
|
||||||
height: 1.1rem;
|
.chart-tip {
|
||||||
background: gray;
|
margin: 0.2rem 0 0;
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
min-height: 1.3em;
|
||||||
}
|
}
|
||||||
.bar-value {
|
@media (min-width: 900px) {
|
||||||
flex: 0 0 auto;
|
.chart-narrow {
|
||||||
text-align: right;
|
display: none;
|
||||||
font-size: 0.95rem;
|
}
|
||||||
}
|
.chart-wide {
|
||||||
|
display: block;
|
||||||
/* ASN-Namen sind länger als ein Datum -> mehr Platz, Umbruch statt Ellipse. */
|
}
|
||||||
#stat-asns .bar-label {
|
|
||||||
flex-basis: 13rem;
|
|
||||||
white-space: normal;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,55 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api, errMsg } from "../api";
|
import { api, errMsg } from "../api";
|
||||||
import type { StatsDetail } from "../types";
|
import type { ChartPair, StatsCharts, StatsDetail } from "../types";
|
||||||
|
|
||||||
// Statistik-Seite: Zahlen + einfache CSS-Balken (keine Chart-Library).
|
// Statistik-Seite: Zahlen und Diagramme, die der Server einmal täglich als SVG
|
||||||
// Balkenbreiten setzt React per CSSOM (element.style), nicht als
|
// rendert (charts.go). Die SVGs kommen ohne style-Attribute aus -> von der
|
||||||
// style-Attribut im HTML -> von der strikten CSP nicht betroffen.
|
// strikten CSP nicht betroffen.
|
||||||
|
|
||||||
// NumCard ist eine Kennzahl-Kachel: große Zahl + Beschriftung darunter.
|
// NumCard ist eine Kennzahl-Kachel: große Zahl + Beschriftung darunter.
|
||||||
function NumCard({ value, label }: { value: number; label: string }) {
|
function NumCard({ value, label }: { value: number; label: string }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<span className="num">{value}</span>
|
<span className="num">{value.toLocaleString("de-DE", { maximumFractionDigits: 1, useGrouping: false })}</span>
|
||||||
<span className="muted">{label}</span>
|
<span className="muted">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bars zeichnet eine Liste {label, value} als horizontale Balken. Die Breite
|
// Chart bettet ein Server-SVG inline ein, damit es die Schrift der Seite erbt.
|
||||||
// ist relativ zum größten Wert.
|
// Der Inhalt stammt vollständig aus charts.go (nur Zahlen und feste Texte).
|
||||||
function Bars({ items, formatLabel }: {
|
// Beide Größen stehen im DOM, das CSS zeigt je nach Fensterbreite eine.
|
||||||
items: { label: string; value: number }[];
|
// Die Infozeile darunter ist immer reserviert (kein Springen) und zeigt den
|
||||||
formatLabel?: (label: string) => string;
|
// data-tip des Datenpunkts unter dem Zeiger; Tippen genügt auf Touch-Geräten.
|
||||||
}) {
|
function Chart({ svg }: { svg?: ChartPair }) {
|
||||||
if (items.length === 0) return <p className="muted">Noch keine Daten.</p>;
|
const [tip, setTip] = useState("");
|
||||||
const max = Math.max(...items.map((it) => it.value), 1);
|
if (!svg) return null;
|
||||||
// Wertespalte für alle Zeilen gleich breit: an der längsten Zahl ausrichten
|
const pick = (e: React.MouseEvent) => {
|
||||||
// (in ch), damit die Tracks bündig sind und nicht je nach Stellenzahl wandern.
|
const el = (e.target as Element).closest("[data-tip]");
|
||||||
const valWidth = Math.max(...items.map((it) => String(it.value).length));
|
if (el) setTip(el.getAttribute("data-tip") ?? "");
|
||||||
return items.map((it) => (
|
};
|
||||||
<div className="bar-row" key={it.label}>
|
return (
|
||||||
<span className="bar-label">{formatLabel ? formatLabel(it.label) : it.label}</span>
|
<div className="chart" onMouseOver={pick} onClick={pick} onMouseLeave={() => setTip("")}>
|
||||||
<div className="bar-track">
|
<div className="chart-narrow" dangerouslySetInnerHTML={{ __html: svg.narrow }} />
|
||||||
<div className="bar-fill" style={{ width: `${(it.value / max) * 100}%` }}></div>
|
<div className="chart-wide" dangerouslySetInnerHTML={{ __html: svg.wide }} />
|
||||||
</div>
|
<p className="chart-tip">{tip || "\u00a0"}</p>
|
||||||
<span className="bar-value" style={{ width: `${valWidth}ch` }}>{it.value}</span>
|
|
||||||
</div>
|
</div>
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dayAxis = (day: string) => day.slice(5); // "MM-DD" reicht auf der Achse
|
|
||||||
|
|
||||||
export function StatsPage() {
|
export function StatsPage() {
|
||||||
const stats = useQuery({
|
const stats = useQuery({
|
||||||
queryKey: ["stats"],
|
queryKey: ["stats"],
|
||||||
queryFn: () => api.get<StatsDetail>("/stats/detail"),
|
queryFn: () => api.get<StatsDetail>("/stats/detail"),
|
||||||
});
|
});
|
||||||
|
const charts = useQuery({
|
||||||
|
queryKey: ["stats-charts"],
|
||||||
|
queryFn: () => api.get<StatsCharts>("/stats/charts"),
|
||||||
|
staleTime: 10 * 60 * 1000,
|
||||||
|
});
|
||||||
const s = stats.data;
|
const s = stats.data;
|
||||||
// Backend liefert die Tage absteigend -> für die Achse drehen.
|
const c = charts.data?.charts;
|
||||||
const daily = s ? s.daily.slice().reverse() : [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
@@ -62,6 +64,7 @@ export function StatsPage() {
|
|||||||
<NumCard value={s.entries} label="Beiträge" />
|
<NumCard value={s.entries} label="Beiträge" />
|
||||||
<NumCard value={s.toplevel} label="davon Threads" />
|
<NumCard value={s.toplevel} label="davon Threads" />
|
||||||
<NumCard value={s.replies} label="davon Antworten" />
|
<NumCard value={s.replies} label="davon Antworten" />
|
||||||
|
<NumCard value={s.entries_per_day_30} label="Ø Beiträge/Tag (30 Tage)" />
|
||||||
<NumCard value={s.votes.yes} label="Stimmen Ja" />
|
<NumCard value={s.votes.yes} label="Stimmen Ja" />
|
||||||
<NumCard value={s.votes.no} label="Stimmen Nein" />
|
<NumCard value={s.votes.no} label="Stimmen Nein" />
|
||||||
</>
|
</>
|
||||||
@@ -82,23 +85,26 @@ export function StatsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Netze nach Betreiber</h3>
|
|
||||||
<p className="muted">
|
|
||||||
Aufrufe je autonomem System (Netzbetreiber), aufgelöst über GeoLite2-ASN.
|
|
||||||
</p>
|
|
||||||
<div id="stat-asns" className="bars">
|
|
||||||
{s && <Bars items={s.top_asns.map((a) => ({ label: a.asn, value: a.hits }))} />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Aufrufe pro Tag</h3>
|
<h3>Aufrufe pro Tag</h3>
|
||||||
<div id="stat-daily" className="bars">
|
<Chart svg={c?.impressions} />
|
||||||
{s && <Bars items={daily.map((d) => ({ label: d.day, value: d.impressions }))} formatLabel={dayAxis} />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Verschiedene Netze pro Tag</h3>
|
<h3>Verschiedene Netze pro Tag</h3>
|
||||||
<div id="stat-networks" className="bars">
|
<Chart svg={c?.networks} />
|
||||||
{s && <Bars items={daily.map((d) => ({ label: d.day, value: d.visitors }))} formatLabel={dayAxis} />}
|
|
||||||
</div>
|
<h3>Beiträge pro Tag</h3>
|
||||||
|
<p className="muted">
|
||||||
|
Alle Beiträge einschließlich gelöschter, in deutscher Zeit. Wochentag und
|
||||||
|
Tageszeit zeigen den Durchschnitt pro Tag seit dem ersten Beitrag.
|
||||||
|
Die Diagramme werden einmal täglich erstellt und enden mit gestern.
|
||||||
|
</p>
|
||||||
|
{charts.isError && <p className="msg">{errMsg(charts.error, "Diagramme konnten nicht geladen werden.")}</p>}
|
||||||
|
<Chart svg={c?.entries} />
|
||||||
|
|
||||||
|
<h3>Beiträge pro Wochentag</h3>
|
||||||
|
<Chart svg={c?.weekday} />
|
||||||
|
|
||||||
|
<h3>Beiträge pro Tageszeit</h3>
|
||||||
|
<Chart svg={c?.hour} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -79,8 +79,19 @@ export interface StatsDetail {
|
|||||||
votes: { yes: number; no: number };
|
votes: { yes: number; no: number };
|
||||||
impressions_total: number;
|
impressions_total: number;
|
||||||
visitors_total: number;
|
visitors_total: number;
|
||||||
daily: { day: string; impressions: number; visitors: number }[];
|
entries_per_day_30: number;
|
||||||
top_asns: { asn: string; hits: number; networks: number }[];
|
}
|
||||||
|
|
||||||
|
// Fertig gerenderte SVG-Diagramme der Statistik-Seite (einmal täglich,
|
||||||
|
// charts.go), je schmal (600) und breit (1200).
|
||||||
|
export interface ChartPair {
|
||||||
|
narrow: string;
|
||||||
|
wide: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsCharts {
|
||||||
|
generated: number;
|
||||||
|
charts: Record<"impressions" | "networks" | "entries" | "weekday" | "hour", ChartPair>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fehlerformat: {"error": {...}}, bei manchen Fehlern mit Zusatzfeldern
|
// Fehlerformat: {"error": {...}}, bei manchen Fehlern mit Zusatzfeldern
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ E-Mail: <a>stress@bnd.wtf</a>
|
|||||||
</ul>
|
</ul>
|
||||||
<em>Zweck:</em> Erfassung des Seitenwachstums und der Reichweite (Seitenaufrufe)<br>
|
<em>Zweck:</em> Erfassung des Seitenwachstums und der Reichweite (Seitenaufrufe)<br>
|
||||||
<em>Rechtsgrundlage:</em> Berechtigtes Interesse (Art. 6 Abs. 1 lit. f DSGVO)<br>
|
<em>Rechtsgrundlage:</em> Berechtigtes Interesse (Art. 6 Abs. 1 lit. f DSGVO)<br>
|
||||||
<em>Hinweis:</em> Durch die Anonymisierung und Aggregation ist keine Rückverfolgung auf einzelne Personen möglich.
|
<em>Hinweis:</em> Die Daten sind so stark gekürzt und zusammengefasst, dass einzelne Personen daraus in der Regel nicht bestimmt werden können.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p><strong>6. Datenlöschung</strong><br><br>
|
<p><strong>6. Datenlöschung</strong><br><br>
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ func main() {
|
|||||||
defer stopImpressionWorker()
|
defer stopImpressionWorker()
|
||||||
startPreviewWorker()
|
startPreviewWorker()
|
||||||
defer stopPreviewWorker()
|
defer stopPreviewWorker()
|
||||||
|
startChartWorker()
|
||||||
|
defer stopChartWorker()
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: envOr("KVER_ADDR", ":8080"),
|
Addr: envOr("KVER_ADDR", ":8080"),
|
||||||
@@ -146,6 +148,7 @@ func routes() http.Handler {
|
|||||||
r.Get("/auth/sessioninfo", api(handleSessionInfo))
|
r.Get("/auth/sessioninfo", api(handleSessionInfo))
|
||||||
r.Get("/stats", api(handleStats))
|
r.Get("/stats", api(handleStats))
|
||||||
r.Get("/stats/detail", api(handleStatsDetail))
|
r.Get("/stats/detail", api(handleStatsDetail))
|
||||||
|
r.Get("/stats/charts", api(handleStatsCharts))
|
||||||
|
|
||||||
// --- Entries (öffentlich) ---
|
// --- Entries (öffentlich) ---
|
||||||
r.Get("/entry/feed/{page}", api(handleFeed))
|
r.Get("/entry/feed/{page}", api(handleFeed))
|
||||||
|
|||||||
+2
-1
@@ -138,7 +138,8 @@ TOR-Anfragen auf `create` werden mit `403 tor.blocked` abgewiesen.
|
|||||||
| Methode | Pfad | Erfolg |
|
| Methode | Pfad | Erfolg |
|
||||||
|---------|------|--------|
|
|---------|------|--------|
|
||||||
| GET | `/api/stats` | `200 {"users","entries"}` |
|
| GET | `/api/stats` | `200 {"users","entries"}` |
|
||||||
| GET | `/api/stats/detail` | `200 {"users","entries","toplevel","replies","votes":{"yes","no"},"impressions_total","visitors_total","daily":[…],"top_asns":[…]}` |
|
| GET | `/api/stats/detail` | `200 {"users","entries","toplevel","replies","votes":{"yes","no"},"impressions_total","visitors_total","entries_per_day_30"}` (Ø Beiträge pro Tag der letzten 30 vollen Tage) |
|
||||||
|
| GET | `/api/stats/charts` | `200 {"generated","charts":{"impressions","networks","entries","weekday","hour"}}`, je `{"narrow","wide"}` als fertiges SVG (600 bzw. 1200 breit), einmal täglich gerendert |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -236,7 +236,8 @@ func handleStats(w http.ResponseWriter, r *http.Request) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleStatsDetail liefert die Kennzahlen für die Statistik-Seite. Anders als
|
// handleStatsDetail liefert die Kennzahlen für die Statistik-Seite (die
|
||||||
|
// Diagramme kommen getrennt aus handleStatsCharts, charts.go). Anders als
|
||||||
// /stats (Tagline, bei jedem Aufruf) darf das hier ruhig mehrere Aggregate
|
// /stats (Tagline, bei jedem Aufruf) darf das hier ruhig mehrere Aggregate
|
||||||
// rechnen. Öffentlich, kein Auth.
|
// rechnen. Öffentlich, kein Auth.
|
||||||
func handleStatsDetail(w http.ResponseWriter, r *http.Request) error {
|
func handleStatsDetail(w http.ResponseWriter, r *http.Request) error {
|
||||||
@@ -257,67 +258,25 @@ func handleStatsDetail(w http.ResponseWriter, r *http.Request) error {
|
|||||||
return Internal(err)
|
return Internal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tägliche Impressions + grobe Unique-Netze, jüngste 30 Tage zuerst. Das
|
// Laufender Durchschnitt: Beiträge (wie in den Diagrammen einschließlich
|
||||||
// Frontend dreht für die Achse auf aufsteigend.
|
// gelöschter) der letzten 30 vollen Tage deutscher Zeit, ohne heute.
|
||||||
daily := []map[string]any{}
|
now := time.Now().In(berlin)
|
||||||
rows, err := db.Query(
|
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, berlin)
|
||||||
`SELECT day, SUM(hits)::bigint, COUNT(DISTINCT anon_ip)
|
var recent int64
|
||||||
FROM impression
|
if err := db.QueryRow(`SELECT COUNT(*) FROM entry WHERE created_at >= $1 AND created_at < $2`,
|
||||||
GROUP BY day
|
midnight.AddDate(0, 0, -30).Unix(), midnight.Unix()).Scan(&recent); err != nil {
|
||||||
ORDER BY day DESC
|
|
||||||
LIMIT 30`)
|
|
||||||
if err != nil {
|
|
||||||
return Internal(err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var day string
|
|
||||||
var hits, visitors int64
|
|
||||||
if err := rows.Scan(&day, &hits, &visitors); err != nil {
|
|
||||||
return Internal(err)
|
|
||||||
}
|
|
||||||
daily = append(daily, map[string]any{"day": day, "impressions": hits, "visitors": visitors})
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return Internal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Netze nach Betreiber (autonomes System). Leere asn ('') -> "(unbekannt)".
|
|
||||||
topASNs := []map[string]any{}
|
|
||||||
rows, err = db.Query(
|
|
||||||
`SELECT CASE WHEN asn = '' THEN '(unbekannt)' ELSE asn END AS a,
|
|
||||||
SUM(hits)::bigint AS h,
|
|
||||||
COUNT(DISTINCT anon_ip)
|
|
||||||
FROM impression
|
|
||||||
GROUP BY a
|
|
||||||
ORDER BY h DESC
|
|
||||||
LIMIT 10`)
|
|
||||||
if err != nil {
|
|
||||||
return Internal(err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var asn string
|
|
||||||
var hits, networks int64
|
|
||||||
if err := rows.Scan(&asn, &hits, &networks); err != nil {
|
|
||||||
return Internal(err)
|
|
||||||
}
|
|
||||||
topASNs = append(topASNs, map[string]any{"asn": asn, "hits": hits, "networks": networks})
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return Internal(err)
|
return Internal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"users": users,
|
"users": users,
|
||||||
"entries": entries,
|
"entries": entries,
|
||||||
"toplevel": toplevel,
|
"toplevel": toplevel,
|
||||||
"replies": entries - toplevel,
|
"replies": entries - toplevel,
|
||||||
"votes": map[string]any{"yes": votesYes, "no": votesNo},
|
"votes": map[string]any{"yes": votesYes, "no": votesNo},
|
||||||
"impressions_total": imprTotal,
|
"impressions_total": imprTotal,
|
||||||
"visitors_total": visitorsTotal,
|
"visitors_total": visitorsTotal,
|
||||||
"daily": daily,
|
"entries_per_day_30": float64(recent) / 30,
|
||||||
"top_asns": topASNs,
|
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user