Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Expand the admin status page into an operational dashboard

4506a4d3d279556435fdd414265e7499c3dada16 · Keenan Nemetz · 14d ago · parent 2e024cc9

forge/pageview/pageview.go +123

@@ -0,0 +1,123 @@
1 // Package pageview keeps daily page-view totals in the instance database.
2 //
3 // The table stores one row per UTC day and account, plus one row for anonymous
4 // viewers. It does not record paths, referrers, addresses, or user agents: the
5 // status page only needs to answer “how much is this instance being read?” and
6 // “how many accounts are reading it?”. Daily aggregation keeps the database
7 // small and makes retention a simple delete if that ever becomes necessary.
8 package pageview
9
10 import (
11 "database/sql"
12 "fmt"
13 "os"
14 "time"
15
16 _ "modernc.org/sqlite"
17 )
18
19 // Count is one period's totals. Users is distinct signed-in accounts; anonymous
20 // views have no account and therefore contribute to Views but not Users.
21 type Count struct {
22 Views int64
23 Users int
24 }
25
26 // Stats is what the status page shows. Periods are inclusive of today.
27 type Stats struct {
28 Today Count
29 Week Count
30 Month Count
31 UTCNow time.Time
32 }
33
34 type Store struct {
35 db *sql.DB
36 }
37
38 // Open creates the table in an existing users.db. The roster database is the
39 // authoritative instance file, so page-view counters travel with a backup and do
40 // not introduce another file an operator has to remember.
41 func Open(path string) (*Store, error) {
42 if _, err := os.Stat(path); err != nil {
43 return nil, fmt.Errorf("pageview: %s is missing; run vole admin init", path)
44 }
45 db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_txlock=immediate")
46 if err != nil {
47 return nil, fmt.Errorf("pageview: %w", err)
48 }
49 db.SetMaxOpenConns(2)
50 if _, err := db.Exec(`
51 CREATE TABLE IF NOT EXISTS page_views (
52 day TEXT NOT NULL,
53 account TEXT NOT NULL DEFAULT '',
54 views INTEGER NOT NULL DEFAULT 0,
55 PRIMARY KEY (day, account)
56 );
57 CREATE INDEX IF NOT EXISTS page_views_day ON page_views(day);`); err != nil {
58 db.Close()
59 return nil, fmt.Errorf("pageview: %w", err)
60 }
61 return &Store{db: db}, nil
62 }
63
64 func (s *Store) Close() error {
65 if s == nil {
66 return nil
67 }
68 return s.db.Close()
69 }
70
71 // Record adds one view. account is empty for an anonymous viewer. The UPSERT
72 // makes this safe when two requests for the same account arrive together.
73 func (s *Store) Record(account string, now time.Time) error {
74 day := dayKey(now)
75 _, err := s.db.Exec(`INSERT INTO page_views(day, account, views) VALUES (?, ?, 1)
76 ON CONFLICT(day, account) DO UPDATE SET views = views + 1`, day, account)
77 if err != nil {
78 return fmt.Errorf("pageview: record: %w", err)
79 }
80 return nil
81 }
82
83 // Stats reads today, the last seven days and the last thirty days. A user who
84 // reads on three days is one unique user in the wider period, which is why the
85 // query counts distinct accounts rather than summing daily user counts.
86 func (s *Store) Stats(now time.Time) (Stats, error) {
87 now = now.UTC()
88 today := dayKey(now)
89 stats := Stats{UTCNow: now}
90 var err error
91 if stats.Today, err = s.count(today); err != nil {
92 return Stats{}, err
93 }
94 if stats.Week, err = s.since(dayKey(now.AddDate(0, 0, -6))); err != nil {
95 return Stats{}, err
96 }
97 if stats.Month, err = s.since(dayKey(now.AddDate(0, 0, -29))); err != nil {
98 return Stats{}, err
99 }
100 return stats, nil
101 }
102
103 func (s *Store) count(day string) (Count, error) {
104 return s.scan(`SELECT COALESCE(SUM(views), 0),
105 COUNT(DISTINCT CASE WHEN account <> '' THEN account END)
106 FROM page_views WHERE day = ?`, day)
107 }
108
109 func (s *Store) since(day string) (Count, error) {
110 return s.scan(`SELECT COALESCE(SUM(views), 0),
111 COUNT(DISTINCT CASE WHEN account <> '' THEN account END)
112 FROM page_views WHERE day >= ?`, day)
113 }
114
115 func (s *Store) scan(query string, arg any) (Count, error) {
116 var c Count
117 if err := s.db.QueryRow(query, arg).Scan(&c.Views, &c.Users); err != nil {
118 return Count{}, fmt.Errorf("pageview: stats: %w", err)
119 }
120 return c, nil
121 }
122
123 func dayKey(t time.Time) string { return t.UTC().Format("2006-01-02") }