From 6d0fc6e4639b9753dee04b5ae41f7bf53696d278 Mon Sep 17 00:00:00 2001 From: Astra Date: Sun, 4 Oct 2026 21:05:57 +0100 Subject: [PATCH] Playtime from PlaytimeHistory's database (SAW_PT_DSN) A public Playtime tab and player-drawer playtime, and staff-only figures (who's on, staff activity, disconnect reasons, countries, names, sessions) for @css/generic on the staff panel and on the public site when signed in. Hidden unless SAW_PT_DSN is set and its tables are there. --- README.md | 53 ++ example.env | 6 + internal/playtime/playtime.go | 771 ++++++++++++++++++++++++++ internal/playtime/playtime_db_test.go | 262 +++++++++ internal/playtime/playtime_test.go | 44 ++ internal/playtime/testdata/schema.sql | 141 +++++ internal/web/admin.go | 3 + internal/web/assets/admin.html | 1 + internal/web/assets/admin.js | 7 +- internal/web/assets/playtime.js | 390 +++++++++++++ internal/web/assets/public.html | 1 + internal/web/assets/public.js | 34 +- internal/web/assets/styles.css | 117 ++++ internal/web/auth.go | 11 +- internal/web/playtime.go | 354 ++++++++++++ internal/web/public.go | 14 +- internal/web/server.go | 13 + main.go | 28 + 18 files changed, 2238 insertions(+), 12 deletions(-) create mode 100644 internal/playtime/playtime.go create mode 100644 internal/playtime/playtime_db_test.go create mode 100644 internal/playtime/playtime_test.go create mode 100644 internal/playtime/testdata/schema.sql create mode 100644 internal/web/assets/playtime.js create mode 100644 internal/web/playtime.go diff --git a/README.md b/README.md index 79fdd17..af92d90 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,10 @@ A SourceBans-style web panel for [CS2-SimpleAdmin](https://github.com/daffyyyy/C and the game server going down, on phones and computers, with quiet hours (see below). - **Verification** (optional): who our GroupVerify plugin verifies, with overrides to give and remove, and its settings. Only appears when GroupVerify is installed (see below). +- **Playtime** (optional): who plays the most, when the server is busy, and each player's time, + stats and maps, from our PlaytimeHistory plugin's database. Staff also see who's on with their + location, staff activity, disconnect reasons and players' name and session history. Only appears + when `SAW_PT_DSN` is set (see below). Live data comes from the **WebPanelBridge** plugin (`plugins/WebPanelBridge` in this repo) over RCON. Without it the panel still works, but shows the server as offline and can't kick or warn. @@ -48,6 +52,8 @@ RCON. Without it the panel still works, but shows the server as offline and can' | Raw console commands (Server settings > Console; sent without waiting, output only logged) | `@css/rcon` | | See the Staff & ranks page | `@zio/webpanel` | | Give and remove verification overrides | `@zio/verify` (not on themselves, unless Root) | + | Staff playtime figures (Playtime page, player drawers, and on the public site when signed in) | `@css/generic` | + | PlaytimeHistory's own status on the Playtime page | `@css/root` | | Ranks, staff, SimpleAdmin settings, reload admins, GroupVerify's group and on/off | `@css/root` | `@css/root` grants everything. Nobody can act on a player whose immunity is higher than theirs, @@ -138,6 +144,43 @@ GroupVerify (0.3.0+) checks each player with the Steam Web API as they join, so members" is everyone Steam has said is in the group when they joined, not the group's full member list. The panel never asks Steam itself. +### Playtime (PlaytimeHistory, optional) + +[PlaytimeHistory](https://git.zio.sh/cs2/PlaytimeHistory) (`plugins/PlaytimeHistory` in the cs2 +repo) records sessions, team and alive/dead time, maps, stats, ping and location in **its own +database**, not SimpleAdmin's. To show it, give the panel a second connection, ideally as a user +that can only read: + +```sql +CREATE USER 'pt_web'@'' IDENTIFIED BY ''; +GRANT SELECT ON playtime_history.* TO 'pt_web'@''; +``` + +``` +SAW_PT_DSN=pt_web:@tcp(:3306)/playtime_history +``` + +- **Nothing shows** without `SAW_PT_DSN`, or while that database is down or doesn't have + PlaytimeHistory's tables. The panel checks once a minute and logs when that changes; it starts + and runs normally either way. +- **Public site**: a Playtime tab (once something's recorded) with the top players for the last 7 + days, 30 days or all time (time on T or CT; spectating doesn't count), the last 7 days against + the 7 before, the busiest hours, players per day, a day-by-hour heatmap and maps. Player pages + get time played with the T/CT/spectating split, the last 30 days, K/D, headshots, damage per + round, MVPs and favourite maps. +- **Staff** with `@css/generic` (PlaytimeHistory's own default for looking up others) get, on the + staff panel's Playtime page and on the public site while signed in: who's on now with how long, + team, ping and location; each staff member's time in the last 7 or 30 days; disconnect reasons; + countries; and in player drawers, the names they've used, recent sessions, location and ping. + Root also sees whether the plugin is loaded and what `css_pt_status` says. Who's on comes from + the live status, so admins hidden with `css_hide` stay hidden. +- **Days and hours of the day** are in the viewer's own time zone (the browser sends it). An hour + goes to the day it starts on, which is exact for zones a whole number of hours from UTC. +- **Locations** come from DB-IP City Lite, credited (CC BY 4.0) wherever they're shown. No IP + addresses are stored. +- `SAW_PT_SERVER_ID` limits it to one `pt_servers.id` when several servers share the database. + All-time totals (pt_players) always cover every server. + ### Name history (optional) SimpleAdmin keeps one row per SteamID + IP in `sa_players_ips` and overwrites its name on each @@ -243,6 +286,16 @@ podman run -d --rm --name sa-test -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE= SAW_TEST_DSN='root:test@tcp(127.0.0.1:33061)/sa?parseTime=true&loc=UTC' go test ./internal/web -run VerifyDB ``` +`internal/playtime/playtime_db_test.go` runs the playtime queries against a real MySQL or MariaDB +when `SAW_TEST_PT_DSN` is set. It drops and recreates PlaytimeHistory's tables from +`internal/playtime/testdata/schema.sql` (a copy of the plugin's migration; keep it in step), so +give it a scratch database: + +``` +podman run -d --rm --name pt-test -e MARIADB_ROOT_PASSWORD=test -e MARIADB_DATABASE=playtime_history -p 127.0.0.1:33071:3306 mariadb:11 +SAW_TEST_PT_DSN='root:test@tcp(127.0.0.1:33071)/playtime_history' go test ./internal/playtime -run DB +``` + `internal/web/notify_db_test.go` does the same for notifications (`-run NotifyDB`): the endpoints, and the watcher sending to a fake push service. It needs only SimpleAdmin's migrations, and empties its ban, mute, admin and group tables and the panel's push tables. diff --git a/example.env b/example.env index 5fc4288..bd17fc0 100644 --- a/example.env +++ b/example.env @@ -13,6 +13,12 @@ SAW_SA_CONFIG=/data/CS2-SimpleAdmin.json # If the host in SimpleAdmin's config isn't reachable from the panel (e.g. a container name): # SAW_DB_HOST=127.0.0.1:3306 +# PlaytimeHistory's database, its own and not SimpleAdmin's. Unset: no playtime anywhere. A user +# with only SELECT on it is enough. +# SAW_PT_DSN=pt_web:password@tcp(db-host:3306)/playtime_history +# Only one server's playtime (pt_servers.id), if several share that database. 0 or unset: all. +# SAW_PT_SERVER_ID=0 + # RCON. By default the address and password come from SimpleAdmin's sa_servers table. # SAW_RCON_ADDR=127.0.0.1:27015 # SAW_RCON_PASSWORD= diff --git a/internal/playtime/playtime.go b/internal/playtime/playtime.go new file mode 100644 index 0000000..9889ec4 --- /dev/null +++ b/internal/playtime/playtime.go @@ -0,0 +1,771 @@ +// Package playtime reads PlaytimeHistory's database (plugins/PlaytimeHistory in the cs2 repo): who +// played when, on which team and map. It's a separate database from SimpleAdmin's, read-only from +// here. All its times are UTC. +package playtime + +import ( + "context" + "database/sql" + "fmt" + "strconv" + "strings" + "time" + + "github.com/go-sql-driver/mysql" +) + +// Teams as PlaytimeHistory records them. +const ( + TeamUnassigned = 0 + TeamSpectator = 1 + TeamT = 2 + TeamCT = 3 +) + +// End kinds of a session (pt_sessions.end_kind). +const ( + EndOpen = 0 + EndDisconnect = 1 + EndMapChange = 2 + EndUnload = 3 + EndStale = 4 // closed at its last heartbeat after the server died +) + +// tables are the ones the panel reads. Without all of them, playtime is off. +var tables = []string{"pt_players", "pt_sessions", "pt_segments", "pt_nicknames", "pt_online_snapshots"} + +type Store struct { + db *sql.DB + server int // pt_servers.id to read, or 0 for every server +} + +// Open connects lazily: a database that's down only turns playtime off, never stops the panel. +func Open(dsn string, server int) (*Store, error) { + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + return nil, err + } + cfg.ParseTime = true + cfg.Loc = time.UTC + db, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(5) + db.SetConnMaxLifetime(5 * time.Minute) + return &Store{db: db, server: server}, nil +} + +// New wraps an open database, for tests. +func New(db *sql.DB, server int) *Store { return &Store{db: db, server: server} } + +func (s *Store) Close() error { return s.db.Close() } + +// and adds the server filter to a WHERE clause on a table with server_id. +func (s *Store) and(col string, args []any) (string, []any) { + if s.server == 0 { + return "", args + } + return " AND " + col + " = ?", append(args, s.server) +} + +// State reports whether PlaytimeHistory's tables are there, and whether it has recorded anything. +func (s *Store) State(ctx context.Context) (hasTables, hasData bool, err error) { + var n int + err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name IN (?, ?, ?, ?, ?)`, + tables[0], tables[1], tables[2], tables[3], tables[4]).Scan(&n) + if err != nil || n < len(tables) { + return false, false, err + } + where, args := s.and("server_id", nil) + err = s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM pt_sessions WHERE 1=1`+where+`)`, args...).Scan(&hasData) + return true, hasData, err +} + +// ---------------------------------------------------------------- Hourly buckets + +// Bucket is the seconds played on T or CT in one UTC hour. +type Bucket struct { + Start time.Time + Seconds int64 +} + +// hours splits closed T/CT segments since a time into UTC hours, for one player or ("") everyone. +// Segments end at the latest on a map change, so none is longer than a day. +func (s *Store) hours(ctx context.Context, since time.Time, steamid uint64) ([]Bucket, error) { + args := []any{since, since, since.Add(-24 * time.Hour)} + filter := "" + if steamid != 0 { + filter = " AND steamid64 = ?" + args = append(args, steamid) + } + where, args := s.and("server_id", args) + rows, err := s.db.QueryContext(ctx, `WITH RECURSIVE seg AS ( + SELECT GREATEST(started_at, ?) AS t0, ended_at AS t1 FROM pt_segments + WHERE ended_at IS NOT NULL AND ended_at > started_at AND team IN (2, 3) + AND ended_at > ? AND started_at >= ?`+filter+where+` + ), h AS ( + SELECT t1, CAST(DATE_FORMAT(t0, '%Y-%m-%d %H:00:00') AS DATETIME) AS b, t0 AS p FROM seg + UNION ALL + SELECT t1, b + INTERVAL 1 HOUR, b + INTERVAL 1 HOUR FROM h WHERE b + INTERVAL 1 HOUR < t1 + ) + SELECT b, SUM(TIMESTAMPDIFF(SECOND, p, LEAST(t1, b + INTERVAL 1 HOUR))) FROM h GROUP BY b ORDER BY b`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Bucket + for rows.Next() { + var b Bucket + if err := rows.Scan(&b.Start, &b.Seconds); err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + +// dayStart is local midnight on t's day in loc. +func dayStart(t time.Time, loc *time.Location) time.Time { + y, m, d := t.In(loc).Date() + return time.Date(y, m, d, 0, 0, 0, 0, loc) +} + +// Day is one local day: hours played on T/CT, or different players who joined. +type Day struct { + Date string `json:"date"` // 2006-01-02, in the viewer's time zone + Hours float64 `json:"hours,omitempty"` + Players int `json:"players,omitempty"` +} + +// lastDays is the n local days ending today, oldest first, and the start of the first. +func lastDays(now time.Time, loc *time.Location, n int) ([]Day, time.Time) { + today := dayStart(now, loc) + days := make([]Day, n) + for i := range days { + days[i].Date = today.AddDate(0, 0, i-n+1).Format(time.DateOnly) + } + return days, today.AddDate(0, 0, 1-n) +} + +// dayIndex finds t's local day in days, or -1. +func dayIndex(days []Day, t time.Time, loc *time.Location) int { + key := t.In(loc).Format(time.DateOnly) + for i := len(days) - 1; i >= 0; i-- { + if days[i].Date == key { + return i + } + } + return -1 +} + +// hoursPerDay adds the buckets up into days. An hour bucket goes to the local day it starts on, +// which is exact for whole-hour time zones. +func hoursPerDay(days []Day, buckets []Bucket, loc *time.Location) { + for _, b := range buckets { + if i := dayIndex(days, b.Start, loc); i >= 0 { + days[i].Hours += float64(b.Seconds) / 3600 + } + } + for i := range days { + days[i].Hours = round1(days[i].Hours) + } +} + +func round1(f float64) float64 { return float64(int64(f*10+0.5)) / 10 } +func round2(f float64) float64 { return float64(int64(f*100+0.5)) / 100 } + +// ---------------------------------------------------------------- Overview (public) + +type Summary struct { + Players int `json:"players"` + PlayersPrev int `json:"playersPrev"` + Hours float64 `json:"hours"` + HoursPrev float64 `json:"hoursPrev"` + Peak int `json:"peak"` + PeakAt *time.Time `json:"peakAt,omitempty"` + NewPlayers int `json:"newPlayers"` +} + +type Leader struct { + SteamID string `json:"steamid"` + Name string `json:"name"` + T float64 `json:"t"` // hours + CT float64 `json:"ct"` + Kills int `json:"kills"` + Deaths int `json:"deaths"` +} + +type MapTime struct { + Map string `json:"map"` + Hours float64 `json:"hours"` + Players int `json:"players,omitempty"` +} + +// Overview is the public Playtime page. Week figures are the last 7 days; charts the last 30 +// (the heatmap the last 28, four of each weekday), in the viewer's time zone. +type Overview struct { + Summary Summary `json:"summary"` + ByHour [24]float64 `json:"byHour"` // average players on T/CT in each local hour of the day + Heat [7][24]float64 `json:"heat"` // the same per weekday, Monday first + Days []Day `json:"days"` + Maps []MapTime `json:"maps"` +} + +func (s *Store) Overview(ctx context.Context, now time.Time, loc *time.Location) (Overview, error) { + var o Overview + days, start := lastDays(now, loc, 30) + buckets, err := s.hours(ctx, start, 0) + if err != nil { + return o, err + } + week, fortnight := now.Add(-7*24*time.Hour), now.Add(-14*24*time.Hour) + heatFrom := dayStart(now, loc).AddDate(0, 0, -27) + for _, b := range buckets { + h := float64(b.Seconds) / 3600 + local := b.Start.In(loc) + o.ByHour[local.Hour()] += h / 30 + if !local.Before(heatFrom) { + o.Heat[(int(local.Weekday())+6)%7][local.Hour()] += h / 4 + } + switch { + case !b.Start.Before(week): + o.Summary.Hours += h + case !b.Start.Before(fortnight): + o.Summary.HoursPrev += h + } + } + // Two decimals: a quiet hour averages well under one player. + for i := range o.ByHour { + o.ByHour[i] = round2(o.ByHour[i]) + } + for d := range o.Heat { + for h := range o.Heat[d] { + o.Heat[d][h] = round2(o.Heat[d][h]) + } + } + o.Summary.Hours, o.Summary.HoursPrev = round1(o.Summary.Hours), round1(o.Summary.HoursPrev) + + where, args := s.and("server_id", []any{week, fortnight, week, fortnight}) + if err := s.db.QueryRowContext(ctx, `SELECT + COUNT(DISTINCT IF(started_at >= ?, steamid64, NULL)), + COUNT(DISTINCT IF(started_at >= ? AND started_at < ?, steamid64, NULL)) + FROM pt_sessions WHERE started_at >= ?`+where, args...).Scan(&o.Summary.Players, &o.Summary.PlayersPrev); err != nil { + return o, err + } + where, args = s.and("server_id", []any{week}) + var peakAt sql.NullTime + err = s.db.QueryRowContext(ctx, `SELECT players, taken_at FROM pt_online_snapshots + WHERE taken_at >= ?`+where+` ORDER BY players DESC, taken_at DESC LIMIT 1`, args...).Scan(&o.Summary.Peak, &peakAt) + if err != nil && err != sql.ErrNoRows { + return o, err + } + if peakAt.Valid { + o.Summary.PeakAt = &peakAt.Time + } + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM pt_players WHERE first_seen >= ?`, week).Scan(&o.Summary.NewPlayers); err != nil { + return o, err + } + + // Different players per local day: by the hour each session started in. + where, args = s.and("server_id", []any{start}) + rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT steamid64, CAST(DATE_FORMAT(started_at, '%Y-%m-%d %H:00:00') AS DATETIME) + FROM pt_sessions WHERE started_at >= ?`+where, args...) + if err != nil { + return o, err + } + seen := make([]map[uint64]bool, len(days)) + for rows.Next() { + var id uint64 + var at time.Time + if err := rows.Scan(&id, &at); err != nil { + rows.Close() + return o, err + } + if i := dayIndex(days, at, loc); i >= 0 { + if seen[i] == nil { + seen[i] = map[uint64]bool{} + } + seen[i][id] = true + } + } + rows.Close() + if err := rows.Err(); err != nil { + return o, err + } + for i := range days { + days[i].Players = len(seen[i]) + } + o.Days = days + + o.Maps, err = s.maps(ctx, 0, &start, 10) + return o, err +} + +// maps is time on T/CT per map, for one player or everyone, since a time or (nil) ever. +func (s *Store) maps(ctx context.Context, steamid uint64, since *time.Time, limit int) ([]MapTime, error) { + q := `SELECT map, SUM(seconds), COUNT(DISTINCT steamid64) FROM pt_segments + WHERE ended_at IS NOT NULL AND team IN (2, 3)` + var args []any + if since != nil { + q += " AND started_at >= ?" + args = append(args, *since) + } + if steamid != 0 { + q += " AND steamid64 = ?" + args = append(args, steamid) + } + where, args := s.and("server_id", args) + rows, err := s.db.QueryContext(ctx, q+where+` GROUP BY map ORDER BY SUM(seconds) DESC LIMIT ?`, append(args, limit)...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []MapTime{} + for rows.Next() { + var m MapTime + var secs int64 + if err := rows.Scan(&m.Map, &secs, &m.Players); err != nil { + return nil, err + } + m.Hours = round1(float64(secs) / 3600) + if steamid != 0 { + m.Players = 0 + } + out = append(out, m) + } + return out, rows.Err() +} + +// Leaders is the most time on T/CT since a time, or (nil) ever, with kills and deaths over the +// same stretch. +func (s *Store) Leaders(ctx context.Context, since *time.Time, limit int) ([]Leader, error) { + var rows *sql.Rows + var err error + if since == nil { + // pt_players' totals cover every server sharing the database. + rows, err = s.db.QueryContext(ctx, `SELECT steamid64, last_nickname, t_seconds, ct_seconds FROM pt_players + WHERE t_seconds + ct_seconds > 0 ORDER BY t_seconds + ct_seconds DESC LIMIT ?`, limit) + } else { + where, args := s.and("g.server_id", []any{*since}) + rows, err = s.db.QueryContext(ctx, `SELECT g.steamid64, COALESCE(MAX(p.last_nickname), ''), + SUM(IF(g.team = 2, g.seconds, 0)), SUM(IF(g.team = 3, g.seconds, 0)) + FROM pt_segments g LEFT JOIN pt_players p ON p.steamid64 = g.steamid64 + WHERE g.ended_at IS NOT NULL AND g.team IN (2, 3) AND g.started_at >= ?`+where+` + GROUP BY g.steamid64 ORDER BY SUM(g.seconds) DESC LIMIT ?`, append(args, limit)...) + } + if err != nil { + return nil, err + } + out := []Leader{} + var ids []uint64 + for rows.Next() { + var id uint64 + var l Leader + var t, ct int64 + if err := rows.Scan(&id, &l.Name, &t, &ct); err != nil { + rows.Close() + return nil, err + } + l.SteamID, l.T, l.CT = strconv.FormatUint(id, 10), round1(float64(t)/3600), round1(float64(ct)/3600) + out = append(out, l) + ids = append(ids, id) + } + rows.Close() + if err := rows.Err(); err != nil || len(ids) == 0 { + return out, err + } + + in, args := inList(ids) + q := `SELECT steamid64, COALESCE(SUM(kills), 0), COALESCE(SUM(deaths), 0) FROM pt_sessions WHERE steamid64 IN (` + in + `)` + if since != nil { + q += " AND started_at >= ?" + args = append(args, *since) + } + where, args := s.and("server_id", args) + krows, err := s.db.QueryContext(ctx, q+where+" GROUP BY steamid64", args...) + if err != nil { + return nil, err + } + defer krows.Close() + kd := map[string][2]int{} + for krows.Next() { + var id uint64 + var k, d int + if err := krows.Scan(&id, &k, &d); err != nil { + return nil, err + } + kd[strconv.FormatUint(id, 10)] = [2]int{k, d} + } + for i := range out { + out[i].Kills, out[i].Deaths = kd[out[i].SteamID][0], kd[out[i].SteamID][1] + } + return out, krows.Err() +} + +func inList(ids []uint64) (string, []any) { + args := make([]any, len(ids)) + for i, id := range ids { + args[i] = id + } + return strings.TrimSuffix(strings.Repeat("?,", len(ids)), ","), args +} + +// ParseID turns a SteamID64 string into the number PlaytimeHistory stores. +func ParseID(steamid string) (uint64, error) { + n, err := strconv.ParseUint(steamid, 10, 64) + if err != nil || n == 0 { + return 0, fmt.Errorf("bad SteamID64 %q", steamid) + } + return n, nil +} + +// ---------------------------------------------------------------- One player + +type Stats struct { + Kills int `json:"kills"` + Deaths int `json:"deaths"` + Assists int `json:"assists"` + Headshots int `json:"headshots"` + Damage int `json:"damage"` + MVPs int `json:"mvps"` + Rounds int `json:"rounds"` +} + +// Player is what anyone can see about one player's playtime. Hours are on T or CT unless named. +type Player struct { + FirstSeen time.Time `json:"firstSeen"` + LastSeen time.Time `json:"lastSeen"` + Sessions int `json:"sessions"` + T float64 `json:"t"` + CT float64 `json:"ct"` + Spec float64 `json:"spec"` // spectating or unassigned + Alive float64 `json:"alive"` + Dead float64 `json:"dead"` + Stats Stats `json:"stats"` + Days []Day `json:"days"` // last 30 days + Maps []MapTime `json:"maps"` +} + +// Player returns nil when PlaytimeHistory has never seen them. +func (s *Store) Player(ctx context.Context, steamid uint64, now time.Time, loc *time.Location) (*Player, error) { + var p Player + var t, ct, spec, alive, dead int64 + err := s.db.QueryRowContext(ctx, `SELECT first_seen, last_seen, sessions_count, t_seconds, ct_seconds, spec_seconds, + alive_seconds, dead_seconds FROM pt_players WHERE steamid64 = ?`, steamid). + Scan(&p.FirstSeen, &p.LastSeen, &p.Sessions, &t, &ct, &spec, &alive, &dead) + if err == sql.ErrNoRows { + return nil, nil + } else if err != nil { + return nil, err + } + h := func(secs int64) float64 { return round1(float64(secs) / 3600) } + p.T, p.CT, p.Spec, p.Alive, p.Dead = h(t), h(ct), h(spec), h(alive), h(dead) + + if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(kills), 0), COALESCE(SUM(deaths), 0), COALESCE(SUM(assists), 0), + COALESCE(SUM(headshots), 0), COALESCE(SUM(damage), 0), COALESCE(SUM(mvp), 0), COALESCE(SUM(rounds_played), 0) + FROM pt_sessions WHERE steamid64 = ?`, steamid). + Scan(&p.Stats.Kills, &p.Stats.Deaths, &p.Stats.Assists, &p.Stats.Headshots, &p.Stats.Damage, &p.Stats.MVPs, &p.Stats.Rounds); err != nil { + return nil, err + } + + days, start := lastDays(now, loc, 30) + buckets, err := s.hours(ctx, start, steamid) + if err != nil { + return nil, err + } + hoursPerDay(days, buckets, loc) + p.Days = days + if p.Maps, err = s.maps(ctx, steamid, nil, 5); err != nil { + return nil, err + } + return &p, nil +} + +type Nickname struct { + Name string `json:"name"` + FirstSeen time.Time `json:"firstSeen"` + LastSeen time.Time `json:"lastSeen"` + Times int `json:"times"` +} + +type Session struct { + Started time.Time `json:"started"` + Ended *time.Time `json:"ended,omitempty"` + Seconds int64 `json:"seconds"` + EndKind int `json:"endKind"` + Map string `json:"map"` + EndMap string `json:"endMap,omitempty"` + Reason string `json:"reason,omitempty"` + Kills *int `json:"kills,omitempty"` + Deaths *int `json:"deaths,omitempty"` + Ping *int `json:"ping,omitempty"` + Country string `json:"country,omitempty"` + City string `json:"city,omitempty"` +} + +// PlayerStaff is what only staff see: names, sessions, location and ping. +type PlayerStaff struct { + Names []Nickname `json:"names"` + Recent []Session `json:"recent"` + Country string `json:"country,omitempty"` + CountryName string `json:"countryName,omitempty"` + City string `json:"city,omitempty"` + Ping *int `json:"ping,omitempty"` // average over the last 30 days +} + +func (s *Store) PlayerStaff(ctx context.Context, steamid uint64, now time.Time) (PlayerStaff, error) { + out := PlayerStaff{Names: []Nickname{}, Recent: []Session{}} + rows, err := s.db.QueryContext(ctx, `SELECT nickname, first_seen, last_seen, times_seen FROM pt_nicknames + WHERE steamid64 = ? ORDER BY last_seen DESC LIMIT 20`, steamid) + if err != nil { + return out, err + } + for rows.Next() { + var n Nickname + if err := rows.Scan(&n.Name, &n.FirstSeen, &n.LastSeen, &n.Times); err != nil { + rows.Close() + return out, err + } + out.Names = append(out.Names, n) + } + rows.Close() + if err := rows.Err(); err != nil { + return out, err + } + + where, args := s.and("server_id", []any{steamid}) + rows, err = s.db.QueryContext(ctx, `SELECT started_at, ended_at, COALESCE(duration_seconds, TIMESTAMPDIFF(SECOND, started_at, heartbeat_at)), + end_kind, connect_map, COALESCE(disconnect_map, ''), COALESCE(disconnect_reason_name, ''), kills, deaths, ping_avg, + COALESCE(country_iso, ''), COALESCE(city, '') + FROM pt_sessions WHERE steamid64 = ?`+where+` ORDER BY started_at DESC LIMIT 10`, args...) + if err != nil { + return out, err + } + for rows.Next() { + var x Session + var ended sql.NullTime + var kills, deaths, ping sql.NullInt64 + if err := rows.Scan(&x.Started, &ended, &x.Seconds, &x.EndKind, &x.Map, &x.EndMap, &x.Reason, &kills, &deaths, &ping, + &x.Country, &x.City); err != nil { + rows.Close() + return out, err + } + if ended.Valid { + x.Ended = &ended.Time + } + x.Kills, x.Deaths, x.Ping = intPtr(kills), intPtr(deaths), intPtr(ping) + out.Recent = append(out.Recent, x) + } + rows.Close() + if err := rows.Err(); err != nil { + return out, err + } + + err = s.db.QueryRowContext(ctx, `SELECT country_iso, COALESCE(country_name, ''), COALESCE(city, '') FROM pt_sessions + WHERE steamid64 = ? AND country_iso IS NOT NULL ORDER BY started_at DESC LIMIT 1`, steamid). + Scan(&out.Country, &out.CountryName, &out.City) + if err != nil && err != sql.ErrNoRows { + return out, err + } + var ping sql.NullFloat64 + if err := s.db.QueryRowContext(ctx, `SELECT AVG(ping_avg) FROM pt_sessions WHERE steamid64 = ? AND started_at >= ?`, + steamid, now.Add(-30*24*time.Hour)).Scan(&ping); err != nil { + return out, err + } + if ping.Valid { + n := int(ping.Float64 + 0.5) + out.Ping = &n + } + return out, nil +} + +func intPtr(n sql.NullInt64) *int { + if !n.Valid { + return nil + } + v := int(n.Int64) + return &v +} + +// ---------------------------------------------------------------- Staff page + +// Online is an open session: when it started, where from, and the player's team right now. +type Online struct { + Since *time.Time `json:"since,omitempty"` + Team *int `json:"team,omitempty"` + Alive *bool `json:"alive,omitempty"` + Country string `json:"country,omitempty"` + City string `json:"city,omitempty"` +} + +// Online looks up the open sessions of these players (the ones the live status lists, so admins +// hidden with css_hide stay hidden). +func (s *Store) Online(ctx context.Context, ids []uint64) (map[string]Online, error) { + out := map[string]Online{} + if len(ids) == 0 { + return out, nil + } + in, args := inList(ids) + where, args := s.and("s.server_id", args) + rows, err := s.db.QueryContext(ctx, `SELECT s.steamid64, s.started_at, g.team, g.alive, COALESCE(s.country_iso, ''), COALESCE(s.city, '') + FROM pt_sessions s LEFT JOIN pt_segments g ON g.session_key = s.session_key AND g.ended_at IS NULL + WHERE s.ended_at IS NULL AND s.steamid64 IN (`+in+`)`+where+` ORDER BY s.started_at`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var id uint64 + var o Online + var since time.Time + var team, alive sql.NullInt64 + if err := rows.Scan(&id, &since, &team, &alive, &o.Country, &o.City); err != nil { + return nil, err + } + o.Since = &since + if team.Valid { + o.Team = intPtr(team) + } + if alive.Valid { + a := alive.Int64 == 1 + o.Alive = &a + } + // The latest session wins if a crash left an older one open. + out[strconv.FormatUint(id, 10)] = o + } + return out, rows.Err() +} + +// Activity is a staff member's time on the server since a time. +type Activity struct { + Hours float64 `json:"hours"` // T or CT + Spec float64 `json:"spec"` + LastSeen *time.Time `json:"lastSeen,omitempty"` +} + +func (s *Store) Activity(ctx context.Context, ids []uint64, since time.Time) (map[string]Activity, error) { + out := map[string]Activity{} + if len(ids) == 0 { + return out, nil + } + in, args := inList(ids) + where, args := s.and("server_id", append(args, since)) + rows, err := s.db.QueryContext(ctx, `SELECT steamid64, SUM(IF(team IN (2, 3), seconds, 0)), SUM(IF(team IN (0, 1), seconds, 0)) + FROM pt_segments WHERE steamid64 IN (`+in+`) AND ended_at IS NOT NULL AND started_at >= ?`+where+` GROUP BY steamid64`, args...) + if err != nil { + return nil, err + } + for rows.Next() { + var id uint64 + var play, spec int64 + if err := rows.Scan(&id, &play, &spec); err != nil { + rows.Close() + return nil, err + } + out[strconv.FormatUint(id, 10)] = Activity{Hours: round1(float64(play) / 3600), Spec: round1(float64(spec) / 3600)} + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + + in, args = inList(ids) + rows, err = s.db.QueryContext(ctx, `SELECT steamid64, last_seen FROM pt_players WHERE steamid64 IN (`+in+`)`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var id uint64 + var seen time.Time + if err := rows.Scan(&id, &seen); err != nil { + return nil, err + } + key := strconv.FormatUint(id, 10) + a := out[key] + a.LastSeen = &seen + out[key] = a + } + return out, rows.Err() +} + +type Reason struct { + Name string `json:"name"` + Count int `json:"count"` + AvgSeconds int64 `json:"avgSeconds"` +} + +// Reasons counts how players disconnected since a time (map changes and crashes aren't reasons). +func (s *Store) Reasons(ctx context.Context, since time.Time) ([]Reason, error) { + where, args := s.and("server_id", []any{EndDisconnect, since}) + rows, err := s.db.QueryContext(ctx, `SELECT COALESCE(disconnect_reason_name, ''), COUNT(*), COALESCE(AVG(duration_seconds), 0) + FROM pt_sessions WHERE end_kind = ? AND started_at >= ?`+where+` + GROUP BY disconnect_reason_name ORDER BY COUNT(*) DESC LIMIT 15`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Reason{} + for rows.Next() { + var r Reason + var avg float64 + if err := rows.Scan(&r.Name, &r.Count, &avg); err != nil { + return nil, err + } + r.AvgSeconds = int64(avg) + out = append(out, r) + } + return out, rows.Err() +} + +type Country struct { + ISO string `json:"iso"` + Name string `json:"name"` + Players int `json:"players"` + Hours float64 `json:"hours"` // connected, any team +} + +// Countries is where players connect from, ever. +func (s *Store) Countries(ctx context.Context) ([]Country, error) { + where, args := s.and("server_id", nil) + rows, err := s.db.QueryContext(ctx, `SELECT country_iso, COALESCE(MAX(country_name), ''), COUNT(DISTINCT steamid64), + COALESCE(SUM(duration_seconds), 0) + FROM pt_sessions WHERE country_iso IS NOT NULL`+where+` + GROUP BY country_iso ORDER BY COUNT(DISTINCT steamid64) DESC LIMIT 20`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Country{} + for rows.Next() { + var c Country + var secs int64 + if err := rows.Scan(&c.ISO, &c.Name, &c.Players, &secs); err != nil { + return nil, err + } + c.Hours = round1(float64(secs) / 3600) + out = append(out, c) + } + return out, rows.Err() +} + +// EndKinds counts how sessions since a time ended, by end_kind. +func (s *Store) EndKinds(ctx context.Context, since time.Time) (map[int]int, error) { + where, args := s.and("server_id", []any{since}) + rows, err := s.db.QueryContext(ctx, `SELECT end_kind, COUNT(*) FROM pt_sessions WHERE started_at >= ?`+where+` GROUP BY end_kind`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int]int{} + for rows.Next() { + var k, n int + if err := rows.Scan(&k, &n); err != nil { + return nil, err + } + out[k] = n + } + return out, rows.Err() +} diff --git a/internal/playtime/playtime_db_test.go b/internal/playtime/playtime_db_test.go new file mode 100644 index 0000000..b8e26e3 --- /dev/null +++ b/internal/playtime/playtime_db_test.go @@ -0,0 +1,262 @@ +package playtime + +import ( + "context" + "database/sql" + _ "embed" + "os" + "strings" + "testing" + "time" + + "github.com/go-sql-driver/mysql" +) + +//go:embed testdata/schema.sql +var schema string + +// TestPlaytimeDB runs every query against a real MySQL/MariaDB holding PlaytimeHistory's schema +// (testdata/schema.sql). It drops and recreates the pt_ tables, so only point it at a scratch +// database: +// +// SAW_TEST_PT_DSN='root:test@tcp(127.0.0.1:33071)/playtime_history' go test ./internal/playtime -run DB +func TestPlaytimeDB(t *testing.T) { + dsn := os.Getenv("SAW_TEST_PT_DSN") + if dsn == "" { + t.Skip("SAW_TEST_PT_DSN not set") + } + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + t.Fatal(err) + } + cfg.ParseTime, cfg.Loc = true, time.UTC + db, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + exec := func(q string, args ...any) { + t.Helper() + if _, err := db.Exec(q, args...); err != nil { + t.Fatalf("%s: %v", q, err) + } + } + exec("DROP VIEW IF EXISTS pt_segments_hourly") + for _, table := range tables { + exec("DROP TABLE IF EXISTS " + table) + } + exec("DROP TABLE IF EXISTS pt_servers") + ctx := context.Background() + st := New(db, 1) + if ok, _, err := st.State(ctx); err != nil || ok { + t.Fatalf("State without tables: %v, %v", ok, err) + } + for stmt := range strings.SplitSeq(schema, ";\n") { + if code := strings.TrimSpace(stripComments(stmt)); code != "" { + exec(code) + } + } + if ok, data, err := st.State(ctx); err != nil || !ok || data { + t.Fatalf("State with empty tables: %v, %v, %v", ok, data, err) + } + + // Times are whole seconds at a few minutes past the hour, so segments cross hour boundaries. + now := time.Now().UTC().Truncate(time.Hour).Add(-50 * time.Minute) + ago := func(d time.Duration) time.Time { return now.Add(-d) } + const a, b = uint64(76561198000001001), uint64(76561198000001002) + exec(`INSERT INTO pt_players (steamid64, first_seen, last_seen, sessions_count, total_seconds, t_seconds, ct_seconds, + spec_seconds, alive_seconds, dead_seconds, last_nickname, last_country, last_server_id) + VALUES (?, ?, ?, 1, 5400, 3600, 1800, 1800, 4000, 1400, 'Alpha', 'GB', 1), + (?, ?, ?, 2, 7200, 7200, 0, 0, 7200, 0, 'Bravo', 'DE', 1)`, + a, ago(3*time.Hour), ago(time.Hour), b, ago(10*24*time.Hour), ago(10*24*time.Hour-2*time.Hour)) + exec(`INSERT INTO pt_nicknames (steamid64, nickname, first_seen, last_seen, times_seen) + VALUES (?, 'Alpha', ?, ?, 3), (?, 'alpha_old', ?, ?, 1)`, a, ago(3*time.Hour), ago(time.Hour), a, ago(5*time.Hour), ago(4*time.Hour)) + session := func(key string, id uint64, start time.Time, end *time.Time, kind int, reason string, kills, deaths int) { + var dur any + if end != nil { + dur = int(end.Sub(start).Seconds()) + } + hb := start + if end != nil { + hb = *end + } + exec(`INSERT INTO pt_sessions (session_key, steamid64, server_id, nickname, started_at, ended_at, heartbeat_at, + duration_seconds, end_kind, connect_map, disconnect_map, disconnect_reason_name, players_online, max_players, + country_iso, country_name, city, kills, deaths, rounds_played, damage, ping_avg, plugin_version) + VALUES (?, ?, 1, 'x', ?, ?, ?, ?, ?, 'de_mirage', 'de_mirage', NULLIF(?, ''), 2, 10, 'GB', 'United Kingdom', 'London', + ?, ?, 10, 900, 20, '0.1.0')`, key, id, start, end, hb, dur, kind, reason, kills, deaths) + } + segment := func(key, sess string, id uint64, team int, start time.Time, end *time.Time) { + var secs any + if end != nil { + secs = int(end.Sub(start).Seconds()) + } + alive := any(nil) + if team >= TeamT { + alive = 1 + } + exec(`INSERT INTO pt_segments (segment_key, session_key, steamid64, server_id, map, team, alive, started_at, ended_at, + heartbeat_at, seconds) VALUES (?, ?, ?, 1, 'de_mirage', ?, ?, ?, ?, ?, ?)`, key, sess, id, team, alive, start, end, start, secs) + } + at := func(d time.Duration) *time.Time { x := ago(d); return &x } + session("a1", a, ago(3*time.Hour), at(time.Hour), EndDisconnect, "NETWORK_DISCONNECT_DISCONNECT_BY_USER", 10, 5) + segment("a1-1", "a1", a, TeamT, ago(3*time.Hour), at(2*time.Hour)) + segment("a1-2", "a1", a, TeamCT, ago(2*time.Hour), at(90*time.Minute)) + segment("a1-3", "a1", a, TeamSpectator, ago(90*time.Minute), at(time.Hour)) + session("b1", b, ago(10*24*time.Hour), at(10*24*time.Hour-2*time.Hour), EndMapChange, "", 3, 3) + segment("b1-1", "b1", b, TeamT, ago(10*24*time.Hour), at(10*24*time.Hour-2*time.Hour)) + session("b2", b, ago(30*time.Minute), nil, EndOpen, "", 0, 0) + segment("b2-1", "b2", b, TeamCT, ago(30*time.Minute), nil) + // Another server's session, which a store for server 1 never counts. + exec(`INSERT INTO pt_sessions (session_key, steamid64, server_id, nickname, started_at, heartbeat_at, end_kind, connect_map, + players_online, max_players, plugin_version) VALUES ('other', ?, 2, 'x', ?, ?, 1, 'de_dust2', 1, 10, '0.1.0')`, a, ago(time.Hour), ago(time.Hour)) + exec(`INSERT INTO pt_online_snapshots (server_id, taken_at, players, bots, max_players, map) + VALUES (1, ?, 5, 0, 10, 'de_mirage'), (1, ?, 9, 0, 10, 'de_mirage'), (2, ?, 20, 0, 10, 'de_dust2')`, + ago(24*time.Hour), ago(48*time.Hour), ago(24*time.Hour)) + + if ok, data, err := st.State(ctx); err != nil || !ok || !data { + t.Fatalf("State with data: %v, %v, %v", ok, data, err) + } + + // The hour split must add up to exactly the segments' lengths. + buckets, err := st.hours(ctx, ago(30*24*time.Hour), a) + if err != nil { + t.Fatal(err) + } + var total int64 + for _, bk := range buckets { + total += bk.Seconds + } + if total != 5400 || len(buckets) < 2 { + t.Fatalf("Alpha's buckets add up to %d s over %d hours, want 5400 s over 2+", total, len(buckets)) + } + // Clamped to the start of the window: only the 30 min since. + if buckets, err = st.hours(ctx, ago(150*time.Minute), a); err != nil { + t.Fatal(err) + } + total = 0 + for _, bk := range buckets { + total += bk.Seconds + } + if total != 3600 { + t.Fatalf("since 2.5 h ago: %d s, want 3600 (30 min of T, 30 of CT)", total) + } + + london, _ := time.LoadLocation("Europe/London") + o, err := st.Overview(ctx, now, london) + if err != nil { + t.Fatal(err) + } + s := o.Summary + if s.Players != 2 || s.PlayersPrev != 1 || s.Hours != 1.5 || s.HoursPrev != 2 || s.Peak != 9 || s.NewPlayers != 1 { + t.Fatalf("summary %+v", s) + } + var byHour float64 + for _, h := range o.ByHour { + byHour += h + } + if byHour < 3.3/30 || byHour > 3.7/30 { // 3.5 h spread over 30 days, rounded per hour + t.Fatalf("byHour adds up to %.3f, want about %.3f", byHour, 3.5/30) + } + // Alpha on one day, Bravo on two days ten days apart. + players := 0 + for _, d := range o.Days { + players += d.Players + } + if len(o.Days) != 30 || players != 3 { + t.Fatalf("%d players over %d days", players, len(o.Days)) + } + if len(o.Maps) != 1 || o.Maps[0].Map != "de_mirage" || o.Maps[0].Hours != 3.5 || o.Maps[0].Players != 2 { + t.Fatalf("maps %+v", o.Maps) + } + + week := ago(7 * 24 * time.Hour) + leaders, err := st.Leaders(ctx, &week, 10) + if err != nil { + t.Fatal(err) + } + if len(leaders) != 1 || leaders[0].Name != "Alpha" || leaders[0].T != 1 || leaders[0].CT != 0.5 || leaders[0].Kills != 10 || leaders[0].Deaths != 5 { + t.Fatalf("leaders this week %+v", leaders) + } + if leaders, err = st.Leaders(ctx, nil, 10); err != nil { + t.Fatal(err) + } + if len(leaders) != 2 || leaders[0].Name != "Bravo" || leaders[0].Kills != 3 { + t.Fatalf("leaders all time %+v", leaders) + } + + p, err := st.Player(ctx, a, now, london) + if err != nil || p == nil { + t.Fatalf("Player: %v, %v", p, err) + } + var days float64 + for _, d := range p.Days { + days += d.Hours + } + if p.T != 1 || p.CT != 0.5 || p.Spec != 0.5 || p.Stats.Kills != 10 || p.Stats.Rounds != 10 || days != 1.5 || len(p.Maps) != 1 { + t.Fatalf("player %+v, %.1f h in days", p, days) + } + if p, err := st.Player(ctx, 76561198000009999, now, london); err != nil || p != nil { + t.Fatalf("unknown player: %v, %v", p, err) + } + + ps, err := st.PlayerStaff(ctx, a, now) + if err != nil { + t.Fatal(err) + } + if len(ps.Names) != 2 || ps.Names[0].Name != "Alpha" || len(ps.Recent) != 1 || ps.Recent[0].Seconds != 7200 || + ps.Recent[0].Reason != "NETWORK_DISCONNECT_DISCONNECT_BY_USER" || ps.Country != "GB" || ps.City != "London" || ps.Ping == nil || *ps.Ping != 20 { + t.Fatalf("staff %+v", ps) + } + + on, err := st.Online(ctx, []uint64{a, b}) + if err != nil { + t.Fatal(err) + } + if len(on) != 1 || on["76561198000001002"].Team == nil || *on["76561198000001002"].Team != TeamCT { + t.Fatalf("online %+v", on) + } + + act, err := st.Activity(ctx, []uint64{a, b}, week) + if err != nil { + t.Fatal(err) + } + if x := act["76561198000001001"]; x.Hours != 1.5 || x.Spec != 0.5 || x.LastSeen == nil { + t.Fatalf("activity %+v", act) + } + if x := act["76561198000001002"]; x.Hours != 0 || x.LastSeen == nil { + t.Fatalf("Bravo's activity %+v", x) + } + + reasons, err := st.Reasons(ctx, ago(30*24*time.Hour)) + if err != nil { + t.Fatal(err) + } + if len(reasons) != 1 || reasons[0].Count != 1 || reasons[0].AvgSeconds != 7200 { + t.Fatalf("reasons %+v", reasons) + } + countries, err := st.Countries(ctx) + if err != nil { + t.Fatal(err) + } + if len(countries) != 1 || countries[0].ISO != "GB" || countries[0].Players != 2 { + t.Fatalf("countries %+v", countries) + } + ends, err := st.EndKinds(ctx, ago(30*24*time.Hour)) + if err != nil { + t.Fatal(err) + } + if ends[EndDisconnect] != 1 || ends[EndMapChange] != 1 || ends[EndOpen] != 1 { + t.Fatalf("end kinds %+v", ends) + } +} + +func stripComments(stmt string) string { + var out []string + for l := range strings.SplitSeq(stmt, "\n") { + if !strings.HasPrefix(strings.TrimSpace(l), "--") { + out = append(out, l) + } + } + return strings.Join(out, "\n") +} diff --git a/internal/playtime/playtime_test.go b/internal/playtime/playtime_test.go new file mode 100644 index 0000000..aa57762 --- /dev/null +++ b/internal/playtime/playtime_test.go @@ -0,0 +1,44 @@ +package playtime + +import ( + "testing" + "time" +) + +// Days and hour buckets follow the viewer's time zone, not UTC. +func TestLastDaysInZone(t *testing.T) { + london, err := time.LoadLocation("Europe/London") + if err != nil { + t.Fatal(err) + } + // 00:30 UTC is 01:30 in London (BST): already the 4th there. + now := time.Date(2026, 10, 4, 0, 30, 0, 0, time.UTC) + days, start := lastDays(now, london, 30) + if len(days) != 30 || days[0].Date != "2026-09-05" || days[29].Date != "2026-10-04" { + t.Fatalf("days run %s to %s (%d)", days[0].Date, days[len(days)-1].Date, len(days)) + } + if want := time.Date(2026, 9, 5, 0, 0, 0, 0, london); !start.Equal(want) { + t.Fatalf("start %v, want %v", start, want) + } + + // 23:00 UTC on the 3rd is midnight on the 4th in London. + hoursPerDay(days, []Bucket{ + {Start: time.Date(2026, 10, 3, 23, 0, 0, 0, time.UTC), Seconds: 3600}, + {Start: time.Date(2026, 10, 3, 22, 0, 0, 0, time.UTC), Seconds: 1800}, + {Start: time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC), Seconds: 3600}, // before the window + }, london) + if days[29].Hours != 1 || days[28].Hours != 0.5 { + t.Fatalf("4th %.1f h, 3rd %.1f h; want 1 and 0.5", days[29].Hours, days[28].Hours) + } +} + +func TestParseID(t *testing.T) { + if n, err := ParseID("76561198164949547"); err != nil || n != 76561198164949547 { + t.Fatalf("got %d, %v", n, err) + } + for _, bad := range []string{"", "0", "-1", "abc", "765611981649495470000"} { + if _, err := ParseID(bad); err == nil { + t.Errorf("%q parsed", bad) + } + } +} diff --git a/internal/playtime/testdata/schema.sql b/internal/playtime/testdata/schema.sql new file mode 100644 index 0000000..84af829 --- /dev/null +++ b/internal/playtime/testdata/schema.sql @@ -0,0 +1,141 @@ +-- A copy of PlaytimeHistory's Migrations/001_CreateTables.sql (plugins/PlaytimeHistory, 0.1.0), for +-- playtime_db_test.go. Keep it in step with the plugin's migrations. + +-- All times are UTC. Tables are prefixed pt_ so they can share a database if ever needed. + +CREATE TABLE IF NOT EXISTS `pt_servers` ( + `id` int NOT NULL, + `address` varchar(64) NOT NULL DEFAULT '', + `hostname` varchar(128) NOT NULL DEFAULT '', + `first_seen` datetime NOT NULL, + `last_seen` datetime NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- One row per connection, opened on join and closed on leave or map change. +-- end_kind: 0 open, 1 disconnect, 2 map change, 3 plugin unload, 4 stale (closed at its last +-- heartbeat after the server died). +CREATE TABLE IF NOT EXISTS `pt_sessions` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `session_key` char(32) NOT NULL, + `steamid64` bigint unsigned NOT NULL, + `server_id` int NOT NULL, + `nickname` varchar(128) NOT NULL, + `started_at` datetime NOT NULL, + `ended_at` datetime NULL, + `heartbeat_at` datetime NOT NULL, + `duration_seconds` int unsigned NULL, + `end_kind` tinyint NOT NULL DEFAULT 0, + `connect_map` varchar(64) NOT NULL, + `disconnect_map` varchar(64) NULL, + `disconnect_reason` smallint NULL, + `disconnect_reason_name` varchar(64) NULL, + `players_online` smallint unsigned NOT NULL, + `max_players` smallint unsigned NOT NULL, + `country_iso` char(2) NULL, + `country_name` varchar(64) NULL, + `city` varchar(128) NULL, + `kills` int NULL, + `deaths` int NULL, + `assists` int NULL, + `headshots` int NULL, + `damage` int NULL, + `mvp` int NULL, + `score` int NULL, + `rounds_played` smallint unsigned NULL, + `ping_avg` smallint unsigned NULL, + `ping_min` smallint unsigned NULL, + `ping_max` smallint unsigned NULL, + `ping_samples` int unsigned NULL, + `plugin_version` varchar(32) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_session_key` (`session_key`), + KEY `ix_steamid_started` (`steamid64`, `started_at`), + KEY `ix_server_started` (`server_id`, `started_at`), + KEY `ix_started` (`started_at`), + KEY `ix_server_open` (`server_id`, `ended_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- A stretch of a session where the player's team and alive/dead state didn't change. +-- team: 0 unassigned, 1 spectator, 2 T, 3 CT. alive: NULL when not on T/CT. +CREATE TABLE IF NOT EXISTS `pt_segments` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `segment_key` char(32) NOT NULL, + `session_key` char(32) NOT NULL, + `steamid64` bigint unsigned NOT NULL, + `server_id` int NOT NULL, + `map` varchar(64) NOT NULL, + `team` tinyint NOT NULL, + `alive` tinyint NULL, + `started_at` datetime NOT NULL, + `ended_at` datetime NULL, + `heartbeat_at` datetime NOT NULL, + `seconds` int unsigned NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_segment_key` (`segment_key`), + KEY `ix_steamid_started` (`steamid64`, `started_at`), + KEY `ix_started` (`started_at`), + KEY `ix_session` (`session_key`), + KEY `ix_server_open` (`server_id`, `ended_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Running totals, added to in the same transaction that closes a segment or session. +-- spec_seconds covers both spectator and unassigned. total_seconds includes it only when the +-- plugin's CountSpectatorTime is on. +CREATE TABLE IF NOT EXISTS `pt_players` ( + `steamid64` bigint unsigned NOT NULL, + `first_seen` datetime NOT NULL, + `last_seen` datetime NOT NULL, + `sessions_count` int unsigned NOT NULL DEFAULT 0, + `total_seconds` bigint unsigned NOT NULL DEFAULT 0, + `t_seconds` bigint unsigned NOT NULL DEFAULT 0, + `ct_seconds` bigint unsigned NOT NULL DEFAULT 0, + `spec_seconds` bigint unsigned NOT NULL DEFAULT 0, + `alive_seconds` bigint unsigned NOT NULL DEFAULT 0, + `dead_seconds` bigint unsigned NOT NULL DEFAULT 0, + `last_nickname` varchar(128) NOT NULL, + `last_country` char(2) NULL, + `last_server_id` int NOT NULL, + PRIMARY KEY (`steamid64`), + KEY `ix_total` (`total_seconds`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `pt_nicknames` ( + `steamid64` bigint unsigned NOT NULL, + `nickname` varchar(128) NOT NULL, + `first_seen` datetime NOT NULL, + `last_seen` datetime NOT NULL, + `times_seen` int unsigned NOT NULL DEFAULT 1, + PRIMARY KEY (`steamid64`, `nickname`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `pt_online_snapshots` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `server_id` int NOT NULL, + `taken_at` datetime NOT NULL, + `players` smallint unsigned NOT NULL, + `bots` smallint unsigned NOT NULL, + `max_players` smallint unsigned NOT NULL, + `map` varchar(64) NOT NULL, + PRIMARY KEY (`id`), + KEY `ix_server_taken` (`server_id`, `taken_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Segments split at the hour boundaries: one row per segment per UTC hour it touches. Closed +-- segments only; filter on hour_start (and steamid64) when reading. +CREATE VIEW IF NOT EXISTS `pt_segments_hourly` AS +WITH RECURSIVE h AS ( + SELECT s.steamid64, s.server_id, s.map, s.team, s.alive, s.ended_at, + CAST(DATE_FORMAT(s.started_at, '%Y-%m-%d %H:00:00') AS DATETIME) AS hour_start, + s.started_at AS piece_start + FROM pt_segments s + WHERE s.ended_at IS NOT NULL AND s.ended_at > s.started_at + UNION ALL + SELECT steamid64, server_id, map, team, alive, ended_at, + hour_start + INTERVAL 1 HOUR, hour_start + INTERVAL 1 HOUR + FROM h + WHERE hour_start + INTERVAL 1 HOUR < ended_at +) +SELECT steamid64, server_id, map, team, alive, hour_start, + TIMESTAMPDIFF(SECOND, piece_start, LEAST(ended_at, hour_start + INTERVAL 1 HOUR)) AS seconds +FROM h; diff --git a/internal/web/admin.go b/internal/web/admin.go index 867281e..e241360 100644 --- a/internal/web/admin.go +++ b/internal/web/admin.go @@ -245,6 +245,9 @@ func (s *Server) adminPlayer(w http.ResponseWriter, r *http.Request, id *Identit } else if v != nil { resp["verify"] = v } + if pt := s.playerPlaytime(r, steamid, s.ptStaff(ctx, id)); pt != nil { + resp["playtime"] = pt + } writeJSON(w, http.StatusOK, resp) } diff --git a/internal/web/assets/admin.html b/internal/web/assets/admin.html index 47c7af4..883b15e 100644 --- a/internal/web/assets/admin.html +++ b/internal/web/assets/admin.html @@ -56,6 +56,7 @@
+ diff --git a/internal/web/assets/admin.js b/internal/web/assets/admin.js index bfcefaa..684d044 100644 --- a/internal/web/assets/admin.js +++ b/internal/web/assets/admin.js @@ -6,6 +6,8 @@ const PAGES = [ { id: "players", label: "Players" }, { id: "bans", label: "Bans" }, { id: "comms", label: "Gags & mutes" }, + // Only when the panel has PlaytimeHistory's database (playtime.js). + { id: "playtime", label: "Playtime", show: () => !!state.me?.playtimeStaff }, { id: "staff", label: "Staff & ranks", flag: "@zio/webpanel" }, // Only when GroupVerify's tables are in the database. { id: "verify", label: "Verification", show: () => !!state.me?.verify }, @@ -798,6 +800,7 @@ async function openPlayer(sid) { ${verifySection(d.verify, p.steamid, name)} + ${ptDrawer(d.playtime, d.online)} ${manage ? `

Rank

@@ -941,7 +944,9 @@ async function act(path, method, body) { /* ---------- Routing & events ---------- */ -const RENDER = { overview, players, bans, comms, staff, verify, settings, notifications }; +const playtime = () => ptPage(state.me, () => state.page === "playtime"); + +const RENDER = { overview, players, bans, comms, playtime, staff, verify, settings, notifications }; function renderNav() { const c = state.counts; diff --git a/internal/web/assets/playtime.js b/internal/web/assets/playtime.js new file mode 100644 index 0000000..d2925cb --- /dev/null +++ b/internal/web/assets/playtime.js @@ -0,0 +1,390 @@ +// Playtime from PlaytimeHistory, shared by the public site and the staff panel: the Playtime page +// and the Playtime sections of a player's drawer. Staff with @css/generic (me.playtimeStaff) also +// get the staff-only figures, on either site. Needs common.js. + +const PT_TZ = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { return "UTC"; } })(); +const PT_PERIODS = [["7d", "Last 7 days"], ["30d", "Last 30 days"], ["all", "All time"]]; +const PT_WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; +const PT_TEAM = { 0: 'Unassigned', 1: 'Spectating', 2: 'T', 3: 'CT' }; +const PT_DBIP = '

Locations from IP Geolocation by DB-IP (CC BY 4.0). The plugin never stores IP addresses.

'; +// The CS2 disconnect reasons players hit most, in words. Anything else shows its own name. +const PT_REASONS = { + NETWORK_DISCONNECT_DISCONNECT_BY_USER: "Left the game", + NETWORK_DISCONNECT_TIMEDOUT: "Timed out", + NETWORK_DISCONNECT_KICKED: "Kicked", + NETWORK_DISCONNECT_STEAM_LOGOFF: "Steam logged off", + NETWORK_DISCONNECT_LOOPDEACTIVATE: "Game closed", + NETWORK_DISCONNECT_LOOPSHUTDOWN: "Game closed", + NETWORK_DISCONNECT_EXITING: "Game closed", + NETWORK_DISCONNECT_REJECT_BANNED: "Banned", + NETWORK_DISCONNECT_KICKBANADDED: "Banned", + NETWORK_DISCONNECT_STEAM_BANNED: "Banned", + NETWORK_DISCONNECT_SHUTDOWN: "Server shut down", +}; + +const ptState = { period: "7d", staffPeriod: "7d" }; + +const ptH = (h) => (h >= 100 ? Math.round(h).toLocaleString() : (Math.round(h * 10) / 10).toFixed(1)) + " h"; +const ptPct = (a, b) => Math.round((a / Math.max(b, 1)) * 100); +const ptKD = (k, d) => (k / Math.max(d, 1)).toFixed(2); +const ptPad = (h) => String(h).padStart(2, "0"); +const ptDur = (s) => (s >= 3600 ? `${Math.floor(s / 3600)}h ${ptPad(Math.floor((s % 3600) / 60))}m` : `${Math.max(1, Math.round(s / 60))}m`); +const ptDay = (iso, opts = { weekday: "short", day: "numeric", month: "short" }) => new Date(`${iso}T12:00:00`).toLocaleDateString(undefined, opts); + +function ptWho(p) { + return `
${avatar(p.steamid, p.name)}
${esc(p.name || "Unknown")}${p.rank ? rankTag(p.rank) : ""}${esc(p.steamid)}
`; +} + +function ptBar(value, max, label, fill) { + return `
${label}
`; +} + +// A clean gridline step for values up to max: about three lines, from 0.1 up. +function ptStep(max, least = 0.1) { + for (const s of [0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500]) if (s >= least && max / s <= 4) return s; + return 1000; +} + +// A column chart: values [{v, tip, tick}], gridlines on clean steps. +function ptColumns(values, { note = "", fill = "", whole = false }) { + const top = Math.max(0, ...values.map((x) => x.v)); + const step = ptStep(top, whole ? 1 : 0.1); + const max = Math.max(step, Math.ceil(top / step - 1e-9) * step); + const grid = []; + for (let i = 1; i * step <= max + 1e-9; i++) { + const g = Math.round(i * step * 10) / 10; + grid.push(`
${g}
`); + } + return `
+
${grid.join("")}${values.map((x) => `
`).join("")}
+
${values.map((x) => `${x.tick ?? ""}`).join("")}
+ ${note ? `

${note}

` : ""} +
`; +} + +function ptSplit(t, ct, spec) { + const total = t + ct + spec; + const seg = (cls, v) => (v > 0 ? `` : ""); + return ` +
    +
  • T ${ptH(t)}
  • +
  • CT ${ptH(ct)}
  • +
  • Spectating ${ptH(spec)}
  • +
`; +} + +/* ---------- Page: public panels ---------- */ + +function ptTiles(s) { + const delta = (a, b) => (b ? `${a >= b ? "+" : ""}${ptPct(a - b, b)}% on the 7 days before` : 'Nothing the 7 days before'); + return `
+
Players, last 7 days${s.players}${delta(s.players, s.playersPrev)}
+
Hours played, last 7 days${Math.round(s.hours).toLocaleString()}${delta(s.hours, s.hoursPrev)}
+
Most online at once${s.peak}${s.peakAt ? fmtDate(s.peakAt) : "No count yet"}
+
New players, last 7 days${s.newPlayers}First time on the server
+
`; +} + +function ptLeaders(d) { + const max = Math.max(0.1, ...d.leaders.map((p) => p.t + p.ct)); + return `
+
+

Top players

Time on T or CT. Spectating doesn't count.

+ ${segmented("ptPeriod", d.period, PT_PERIODS)} +
+ ${d.leaders.length ? `
+ + ${d.leaders.map((p, i) => ` + + + + + + `).join("")} +
#PlayerTime playedT / CTK/D
${i + 1}${ptWho(p)}${ptBar(p.t + p.ct, max, ptH(p.t + p.ct))}${p.t.toFixed(1)} / ${p.ct.toFixed(1)}${ptKD(p.kills, p.deaths)}
` : '
Nobody has played on a team in this time.
'} +
`; +} + +function ptBusiest(o) { + const vals = o.byHour.map((v, h) => ({ v, tick: h % 3 === 0 ? ptPad(h) : "", tip: `${ptPad(h)}:00 · ${v} players on average` })); + const best = o.byHour.indexOf(Math.max(...o.byHour)); + return `
+

Busiest hours

Players on T or CT, averaged over the last 30 days, in your time zone.

+ ${ptColumns(vals, { note: o.byHour[best] ? `Busiest at ${ptPad(best)}:00, with ${o.byHour[best]} players on average.` : "" })} +
`; +} + +function ptDaily(o) { + const vals = o.days.map((d, i) => ({ v: d.players || 0, tick: i % 5 === 0 ? ptDay(d.date, { day: "numeric" }) : "", tip: `${ptDay(d.date)} · ${d.players || 0} players` })); + return `
+

Players per day

Different players who joined each day, last 30 days.

+ ${ptColumns(vals, { fill: "var(--ct)", whole: true })} +
`; +} + +function ptHeatmap(o) { + const max = Math.max(0.1, ...o.heat.flat()); + const step = (v) => (v <= 0.05 ? 0 : Math.min(5, Math.ceil((v / max) * 5))); + return `
+

When people play

Average players on T or CT by day and hour, last 4 weeks, in your time zone.

+
+ ${o.heat[0].map((_, h) => ``).join("")} + ${o.heat.map((row, wd) => `${row.map((v, h) => ``).join("")}`).join("")} +
${h % 3 === 0 ? ptPad(h) : ""}
${PT_WEEKDAYS[wd]}
+
Fewer ${[0, 1, 2, 3, 4, 5].map((n) => ``).join("")} More
+
`; +} + +function ptMapsPanel(o) { + const max = Math.max(0.1, ...o.maps.map((m) => m.hours)); + return `
+

Maps

Hours played on each map, last 30 days.

+ ${o.maps.length ? `
+ + ${o.maps.map((m) => ``).join("")} +
MapHours playedPlayers
${esc(m.map)}${ptBar(m.hours, max, m.hours.toLocaleString(), "var(--t)")}${m.players}
` : '
No maps played in the last 30 days.
'} +
`; +} + +function ptPublicPanels(d) { + const o = d.overview; + return `${ptTiles(o.summary)}
${ptLeaders(d)}
+
${ptBusiest(o)}${ptDaily(o)}
${ptHeatmap(o)}${ptMapsPanel(o)}`; +} + +/* ---------- Page: staff panels ---------- */ + +function ptOnline(a) { + return `
+

On the server now

How long each player has been on, their team and where they're connecting from.

+ ${a.online.length ? `
+ + ${a.online.map((p) => ` + + + + + + `).join("")} +
PlayerOnline forTeamPingFrom
${ptWho(p)}${p.since ? ptDur((Date.now() - new Date(p.since)) / 1000) : 'Not recorded'}${PT_TEAM[p.team] || ""}${p.alive === false ? ' dead' : ""}${p.ping > 100 ? `${p.ping}` : `${p.ping}`}${p.country ? `${p.city ? `${esc(p.city)}, ` : ""}${esc(p.country)}` : 'Unknown'}
${PT_DBIP}` : '
Nobody is on the server.
'} +
`; +} + +function ptActivity(a) { + const max = Math.max(0.1, ...a.staff.map((s) => s.hours)); + return `
+

Staff activity

Time each staff member spent on the server.

+ ${segmented("ptStaffPeriod", a.period, PT_PERIODS.slice(0, 2))}
+ ${a.staff.length ? `
+ + ${a.staff.map((s) => ` + + + + + `).join("")} +
StaffOn T or CTSpectatingLast seen
${ptWho(s)}${s.hours ? ptBar(s.hours, max, ptH(s.hours), "var(--green)") : 'Not on a team'}${s.spec ? ptH(s.spec) : "–"}${s.online ? 'Online now' : s.lastSeen ? fmtAgo(s.lastSeen) : 'Never'}
` : '
No staff yet.
'} +
`; +} + +function ptReasons(a) { + const total = a.reasons.reduce((n, r) => n + r.count, 0); + const max = Math.max(1, ...a.reasons.map((r) => r.count)); + return `
+

Why people leave

Disconnect reasons, last 30 days. Lots of timeouts can mean a network problem.

+ ${a.reasons.length ? `
+ + ${a.reasons.map((r) => ` + `).join("")} +
ReasonDisconnectsStayed
${esc(PT_REASONS[r.name] || r.name || "Unknown")}${PT_REASONS[r.name] ? `${esc(r.name)}` : ""}${ptBar(r.count, max, `${r.count} · ${ptPct(r.count, total)}%`, "var(--amber)")}${ptDur(r.avgSeconds)} avg.
` : '
No disconnects in the last 30 days.
'} +
`; +} + +function ptCountries(a) { + const max = Math.max(1, ...a.countries.map((c) => c.players)); + return `
+

Where players are from

Different players per country, all time.

+ ${a.countries.length ? `
+ + ${a.countries.map((c) => ``).join("")} +
CountryPlayersHours
${esc(c.name || c.iso)} ${esc(c.iso)}${ptBar(c.players, max, c.players, "var(--ct)")}${Math.round(c.hours).toLocaleString()}
${PT_DBIP}` : '
No locations recorded yet.
'} +
`; +} + +function ptRecorder(a) { + const r = a.recorder; + if (!r) return ""; + const e = a.endKinds; + const plugin = { loaded: `Loaded ${esc(r.version)}`, missing: 'Not loaded', unknown: 'Unknown the game server isn\'t answering' }[r.plugin] || ""; + return `
+

Recorder

PlaytimeHistory on the game server (css_pt_status), and how sessions ended in the last 30 days. Only Root sees this.

+
+
Plugin
${plugin}
+ ${r.lines.length ? `
Status
    ${r.lines.map((l) => `
  • ${esc(l)}
  • `).join("")}
` : ""} +
Sessions ended
${e.disconnect.toLocaleString()} by disconnect, ${e.mapChange.toLocaleString()} by map change, ${e.unload} by plugin unload
+
Crashes
${e.stale ? `${e.stale} sessions closed at their last heartbeat` : "None"}
+
+
`; +} + +function ptStaffPanels(a) { + return `
${ptOnline(a)}
${ptActivity(a)}
${ptReasons(a)}${ptCountries(a)}${ptRecorder(a)}
`; +} + +/* ---------- Page ---------- */ + +// ptPage draws the Playtime page into #view: the staff panels first when this viewer may see them, +// then the public ones. isCurrent says whether the user is still on the page when data arrives. +async function ptPage(me, isCurrent) { + const v = $("#view"); + const staff = !!me?.playtimeStaff; + v.innerHTML = ` +

Playtime

${staff + ? "Who's on, staff activity and where players come from, then everything players see on the public site." + : "Who plays the most, and when the server is busiest. Click a player for their stats."}

+ ${staff ? `
${loading()}

${me.playtime === "on" ? "On the public site" : ""}

` : ""} +
${me?.playtime === "on" ? loading() : ""}
`; + if (me?.playtime !== "on") { + $("#pt-public").innerHTML = '
PlaytimeHistory hasn\'t recorded anything yet. Playtime appears on the public site once it has.
'; + } + const jobs = []; + if (staff) { + jobs.push(api(`/api/admin/playtime${qs({ period: ptState.staffPeriod })}`).then((a) => { + if (isCurrent()) $("#pt-staff").innerHTML = ptStaffPanels(a); + }).catch((err) => { if (isCurrent()) $("#pt-staff").innerHTML = errorBox(err); })); + } + if (me?.playtime === "on") { + jobs.push(api(`/api/public/playtime${qs({ period: ptState.period, tz: PT_TZ })}`).then((d) => { + if (isCurrent()) $("#pt-public").innerHTML = ptPublicPanels(d); + }).catch((err) => { if (isCurrent()) $("#pt-public").innerHTML = errorBox(err); })); + } + await Promise.all(jobs); +} + +// Switching a period redraws only that panel. +async function ptSwitch(key, value) { + if (key === "ptPeriod") { + ptState.period = value; + try { + const d = await api(`/api/public/playtime${qs({ period: value, tz: PT_TZ })}`); + const box = $("#pt-leaders"); + if (box) box.innerHTML = ptLeaders(d); + } catch (err) { toast(err.message); } + } else if (key === "ptStaffPeriod") { + ptState.staffPeriod = value; + try { + const a = await api(`/api/admin/playtime${qs({ period: value })}`); + const box = $("#pt-activity"); + if (box) box.innerHTML = ptActivity(a); + } catch (err) { toast(err.message); } + } +} + +/* ---------- Player drawer ---------- */ + +// ptDrawer is the Playtime sections of a player's drawer, from the drawer API's "playtime". +function ptDrawer(pt, online) { + if (!pt) return ""; + const p = pt.player; + let out; + if (!p) { + out = `

Playtime

PlaytimeHistory hasn't recorded this player.

`; + } else { + const played = p.t + p.ct; + const maxDay = Math.max(0.1, ...p.days.map((d) => d.hours || 0)); + const maxMap = Math.max(0.1, ...p.maps.map((m) => m.hours)); + const st = p.stats; + out = `
+

Playtime

+
+
Time played
${ptH(played)} on T or CT
+
First seen
${fmtDate(p.firstSeen)}
+
Last seen
${online ? 'On the server now' : fmtAgo(p.lastSeen)}
+
Visits
${p.sessions.toLocaleString()} ${p.sessions === 1 ? "session" : "sessions"}
+
+ ${played + p.spec > 0 ? ptSplit(p.t, p.ct, p.spec) : ""} + ${p.alive + p.dead > 0 ? `

Alive for ${ptPct(p.alive, p.alive + p.dead)}% of their time on a team.

` : ""} +
+
+

Last 30 days

+ +
+ ${st.kills + st.deaths > 0 ? `
+

Stats

+
+
${ptKD(st.kills, st.deaths)}K/D
+
${ptPct(st.headshots, st.kills)}%Headshot kills
+
${st.rounds ? Math.round(st.damage / st.rounds) : "–"}Damage per round
+
${st.mvps}MVPs
+
+

${st.kills.toLocaleString()} kills, ${st.deaths.toLocaleString()} deaths and ${st.assists.toLocaleString()} assists over ${st.rounds.toLocaleString()} rounds.

+
` : ""} + ${p.maps.length ? `
+

Favourite maps

+
${p.maps.map((m) => `
${esc(m.map)}${ptBar(m.hours, maxMap, ptH(m.hours), "var(--t)")}
`).join("")}
+
` : ""}`; + } + const s = pt.staff; + if (!s) return out; + const reason = (x) => { + if (x.endKind === 0) return 'On now'; + if (x.endKind === 2) return "Map change"; + if (x.endKind === 3) return "Plugin unloaded"; + if (x.endKind === 4) return 'Server crashed'; + return esc(PT_REASONS[x.reason] || x.reason || "Left"); + }; + return `${out} + ${s.names.length ? `
+

Names used

+
    ${s.names.map((n) => `
  • ${esc(n.name)} ×${n.times}${fmtDate(n.firstSeen)} – ${fmtDate(n.lastSeen)}
  • `).join("")}
+
` : ""} + ${s.recent.length ? `
+

Recent sessions

+
    ${s.recent.map((x) => `
  • +
    ${fmtDate(x.started)}${ptDur(x.seconds)}
    +
    ${esc(x.map)}${x.endMap && x.endMap !== x.map ? ` → ${esc(x.endMap)}` : ""}${x.kills != null ? ` · ${x.kills}–${x.deaths}` : ""}${x.ping != null ? ` · ${x.ping} ms` : ""}
    +
    ${reason(x)}
    +
  • `).join("")}
+
` : ""} + ${s.country || s.ping != null ? `
+

Connection

+
+ ${s.country ? `
Location
${s.city ? `${esc(s.city)}, ` : ""}${esc(s.countryName || s.country)}
` : ""} + ${s.ping != null ? `
Average ping
${s.ping} ms last 30 days
` : ""} +
+
${s.country ? PT_DBIP : ""}` : ""}`; +} + +/* ---------- Events ---------- */ + +document.addEventListener("click", (e) => { + const seg = e.target.closest('[data-seg="ptPeriod"] button, [data-seg="ptStaffPeriod"] button'); + if (!seg) return; + seg.parentElement.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", String(b === seg))); + ptSwitch(seg.parentElement.dataset.seg, seg.dataset.value); +}); + +// One tooltip for every [data-tip] mark, on hover and on keyboard focus. +const ptTip = document.createElement("div"); +ptTip.id = "pt-tip"; +ptTip.hidden = true; +document.body.append(ptTip); +function ptShowTip(el, x, y) { + ptTip.textContent = el.dataset.tip; + ptTip.hidden = false; + const r = ptTip.getBoundingClientRect(); + ptTip.style.left = `${Math.min(window.innerWidth - r.width - 8, Math.max(8, x - r.width / 2))}px`; + ptTip.style.top = `${Math.max(8, y - r.height - 12)}px`; +} +document.addEventListener("mousemove", (e) => { + const el = e.target.closest?.("[data-tip]"); + if (el) ptShowTip(el, e.clientX, e.clientY); + else ptTip.hidden = true; +}); +document.addEventListener("focusin", (e) => { + const el = e.target.closest?.("[data-tip]"); + if (!el) return; + const r = el.getBoundingClientRect(); + ptShowTip(el, r.left + r.width / 2, r.top); +}); +document.addEventListener("focusout", () => { ptTip.hidden = true; }); diff --git a/internal/web/assets/public.html b/internal/web/assets/public.html index feaecb7..e30a9e0 100644 --- a/internal/web/assets/public.html +++ b/internal/web/assets/public.html @@ -34,6 +34,7 @@
+ diff --git a/internal/web/assets/public.js b/internal/web/assets/public.js index bc0ea2c..518d39d 100644 --- a/internal/web/assets/public.js +++ b/internal/web/assets/public.js @@ -1,14 +1,18 @@ -// Public, read-only site: the live server, bans, gags/mutes, and staff with their rank changes. The -// API leaves out who issued penalties and IPs; nothing here can change anything. +// Public, read-only site: the live server, bans, gags/mutes, staff with their rank changes, and +// playtime (playtime.js). The API leaves out who issued penalties and IPs; nothing here can change +// anything. Signed-in staff with @css/generic also see staff playtime figures here. const PAGES = [ { id: "server", label: "Server" }, { id: "bans", label: "Bans" }, { id: "comms", label: "Gags & mutes" }, { id: "staff", label: "Staff" }, + // Once PlaytimeHistory has recorded something, or for staff who see its staff figures. + { id: "playtime", label: "Playtime", show: () => state.me?.playtime === "on" || !!state.me?.playtimeStaff }, ]; -const state = { page: "server", banFilter: "active", commFilter: "active", query: "", num: 1 }; +// me is null until /api/me answers. +const state = { page: "server", banFilter: "active", commFilter: "active", query: "", num: 1, me: null }; function siteName(title) { const i = title.indexOf("."); @@ -242,6 +246,7 @@ async function openPlayer(sid) { ${now.length || p.online || p.verified ? `
${p.verified ? VERIFIED_TAG : ""}${p.online ? 'On the server' : ""}${now.map((h) => `${h.kind === "ban" ? "Banned" : COMM_HELP[h.type]}${h.duration ? `, ${fmtLeft(served(h).left)}` : ", permanently"}`).join("")}
` : ""} + ${ptDrawer(p.playtime, p.online)}

History

${history.length ? `
    ${history.map((h) => `
  • @@ -256,11 +261,20 @@ async function openPlayer(sid) { /* ---------- Routing & events ---------- */ -const RENDER = { server, bans, comms, staff }; +function playtime() { + if (!state.me) { $("#view").innerHTML = loading(); return; } + ptPage(state.me, () => state.page === "playtime"); +} + +const RENDER = { server, bans, comms, staff, playtime }; + +// A page is shown if it has no condition, or (once /api/me has answered) meets it. +const pageShown = (p) => !p.show || p.show(); function route() { const [page, query] = location.hash.slice(1).split("?"); - state.page = RENDER[page] ? page : "server"; + const def = PAGES.find((p) => p.id === page); + state.page = def && (pageShown(def) || !state.me) ? page : "server"; // A link like #bans?q= (from a staff notification) opens the list searching for that // player, showing every status so lifted and expired ones are there too. const q = new URLSearchParams(query || "").get("q"); @@ -269,7 +283,7 @@ function route() { state.num = 1; state[state.page === "bans" ? "banFilter" : "commFilter"] = "all"; } - $("#nav-links").innerHTML = PAGES.map((p) => `
  • ${p.label}
  • `).join(""); + $("#nav-links").innerHTML = PAGES.filter(pageShown).map((p) => `
  • ${p.label}
  • `).join(""); RENDER[state.page](); } @@ -283,6 +297,7 @@ document.addEventListener("input", (e) => { document.addEventListener("click", (e) => { const t = e.target; const seg = t.closest("[data-seg] button"); + if (seg && seg.parentElement.dataset.seg.startsWith("pt")) return; // playtime.js if (seg) { state[seg.parentElement.dataset.seg] = seg.dataset.value; state.num = 1; @@ -303,10 +318,15 @@ document.addEventListener("keydown", (e) => { }); api("/api/me").then((me) => { + state.me = me; $("#site-name").innerHTML = siteName(me.site); document.title = `${me.site} bans and staff`; if (me.staff) $(".signin").innerHTML = "Staff panel"; -}).catch(() => {}); +}).catch(() => { state.me = {}; }).finally(() => { + // The Playtime tab depends on /api/me, so draw the nav (and a Playtime page) again. + if (state.page === "playtime") route(); + else $("#nav-links").innerHTML = PAGES.filter(pageShown).map((p) => `
  • ${p.label}
  • `).join(""); +}); // Keep the scoreboard current while it's on screen. setInterval(() => { if (state.page === "server" && !document.hidden && !$("#drawer-root").innerHTML) server(); }, 15000); diff --git a/internal/web/assets/styles.css b/internal/web/assets/styles.css index 59b7aa0..7586e05 100644 --- a/internal/web/assets/styles.css +++ b/internal/web/assets/styles.css @@ -1082,3 +1082,120 @@ a.btn { text-decoration: none; } background: var(--accent-soft); font-size: 14px; } + +/* ---------- Playtime (PlaytimeHistory) ---------- */ + +:root { + --t: #e3b353; + --ct: #6fa3e8; + --spec: #4b5162; + /* The heatmap's one-hue ramp, from the surface up to the accent. */ + --heat-0: #23262e; + --heat-1: #3a1f29; + --heat-2: #5e1c2f; + --heat-3: #8f1934; + --heat-4: #c41438; + --heat-5: #fe113d; +} + +.pt-tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; } +.pt-tile { display: grid; gap: 4px; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--surface); } +.pt-tile small { color: var(--muted); font-size: 13px; } +.pt-tile b { font: 600 30px/1 var(--display); } +.pt-tile .delta { font-size: 13px; color: var(--muted); } +.pt-tile .delta.up { color: var(--green); } + +.pt-hbar { display: flex; align-items: center; gap: 10px; min-width: 140px; } +.pt-hbar .track { flex: 1; height: 8px; border-radius: 4px; background: var(--line-soft); overflow: hidden; } +.pt-hbar .fill { height: 100%; border-radius: 0 4px 4px 0; background: var(--fill, var(--accent)); } +.pt-hbar span { min-width: 3.5em; text-align: right; font-size: 14px; } + +/* T / CT / spectating as one stacked bar, 2px surface gaps between the parts. */ +.pt-split { display: flex; gap: 2px; height: 14px; margin: 4px 0 10px; } +.pt-split i { display: block; height: 100%; } +.pt-split i:first-child { border-radius: 4px 0 0 4px; } +.pt-split i:last-child { border-radius: 0 4px 4px 0; } +.pt-split i:only-child { border-radius: 4px; } +.pt-split .t { background: var(--t); } +.pt-split .ct { background: var(--ct); } +.pt-split .spec { background: var(--spec); } +.pt-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 0; padding: 0; list-style: none; font-size: 14px; } +.pt-legend li { display: flex; align-items: center; gap: 6px; } +.pt-legend i { width: 10px; height: 10px; border-radius: 2px; background: var(--c); } +.pt-legend span { color: var(--muted); } + +.pt-cols { padding: 18px 18px 12px; } +.pt-cols .plot { + position: relative; + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + align-items: end; + gap: 2px; + height: 140px; + border-bottom: 1px solid var(--line); +} +.pt-cols .grid { position: absolute; left: 0; right: 0; height: 0; border-top: 1px solid var(--line-soft); pointer-events: none; } +.pt-cols .grid span { position: absolute; right: 0; top: -17px; font-size: 11px; color: var(--faint); } +.pt-cols .bar { position: relative; height: 100%; display: flex; align-items: end; justify-content: center; } +.pt-cols .bar i { display: block; width: 100%; max-width: 24px; border-radius: 4px 4px 0 0; background: var(--fill, var(--accent)); opacity: 0.85; } +.pt-cols .bar:hover i, .pt-cols .bar:focus i { opacity: 1; outline: 1px solid var(--text); outline-offset: 1px; } +.pt-cols .ticks { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 2px; margin-top: 6px; font-size: 11px; color: var(--faint); text-align: center; } +.pt-cols .note { margin: 10px 0 0; font-size: 13px; color: var(--muted); } + +.pt-heat { padding: 18px; overflow-x: auto; } +.pt-heat table { border-collapse: separate; border-spacing: 2px; width: auto; min-width: 100%; } +.pt-heat th, .pt-heat td { padding: 0; border: 0; background: none; } +.pt-heat th { font: 500 11px/1 var(--body); color: var(--faint); text-transform: none; letter-spacing: 0; } +.pt-heat th[scope="row"] { padding-right: 8px; text-align: right; font-size: 12px; color: var(--muted); } +.pt-heat td i { display: block; height: 22px; min-width: 16px; border-radius: 3px; background: var(--heat-0); } +.pt-heat td i:hover { outline: 1px solid var(--text); outline-offset: 1px; } +.pt-heat .scale { display: flex; align-items: center; gap: 4px; margin-top: 12px; font-size: 12px; color: var(--muted); } +.pt-heat .scale i { width: 18px; height: 10px; border-radius: 2px; } + +.pt-place { display: inline-block; width: 2ch; font: 700 20px/1 var(--display); color: var(--faint); } +tr:nth-child(1) .pt-place { color: #f5c542; } +tr:nth-child(2) .pt-place { color: #c9ced8; } +tr:nth-child(3) .pt-place { color: #d08a52; } +.pt-who .rank { margin-left: 6px; } + +.pt-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; } +.pt-stats div { display: grid; gap: 2px; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--bg); } +.pt-stats b { font: 600 22px/1 var(--display); } +.pt-stats small { color: var(--muted); font-size: 12px; } +.pt-spark { display: flex; align-items: end; gap: 2px; height: 48px; margin-top: 4px; } +.pt-spark i { flex: 1; max-width: 12px; min-height: 2px; border-radius: 3px 3px 0 0; background: var(--accent); opacity: 0.85; } +.pt-spark i.zero { background: var(--line); opacity: 1; } +.pt-maps { display: grid; gap: 8px; } +.pt-maps div { display: grid; grid-template-columns: 110px 1fr; align-items: center; gap: 10px; font-size: 14px; } +.pt-names { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: 14px; } +.pt-names li { display: flex; justify-content: space-between; gap: 12px; } +.pt-sessions { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.pt-sessions li { display: grid; gap: 4px; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--bg); font-size: 14px; } +.pt-sessions .top { display: flex; justify-content: space-between; gap: 10px; } +.pt-credit { margin: 0; padding: 12px 18px; border-top: 1px solid var(--line); font-size: 12px; color: var(--faint); } +.pt-credit a { color: var(--muted); } +.drawer .pt-credit { border-top: 0; padding: 14px 22px; } +.pt-lines { margin: 0; padding: 0; list-style: none; display: grid; gap: 4px; font-size: 14px; } +.pt-stack > *, .pt-grid > * { min-width: 0; } +.pt-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; align-items: start; } + +#pt-tip { + position: fixed; + z-index: 50; + padding: 6px 10px; + border: 1px solid var(--line); + border-radius: var(--r-sm); + background: var(--raised); + font-size: 13px; + white-space: nowrap; + pointer-events: none; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); +} +#pt-tip[hidden] { display: none; } + +@media (max-width: 820px) { + .pt-tiles, .pt-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .pt-grid { grid-template-columns: minmax(0, 1fr); } + .pt-cols .ticks span:nth-child(even) { visibility: hidden; } +} diff --git a/internal/web/auth.go b/internal/web/auth.go index 268fbcf..ba79f76 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -182,8 +182,13 @@ type meView struct { Perms []string `json:"perms,omitempty"` Immunity int `json:"immunity,omitempty"` // Verify is true when GroupVerify's tables are in the database, for the Verification page. - Verify bool `json:"verify,omitempty"` - Site string `json:"site"` + Verify bool `json:"verify,omitempty"` + // Playtime is "on" once PlaytimeHistory has recorded something, "empty" before that, and + // left out when the panel has no playtime database (see playtime.go). + Playtime string `json:"playtime,omitempty"` + // PlaytimeStaff: this viewer sees the staff-only playtime figures, on either site. + PlaytimeStaff bool `json:"playtimeStaff,omitempty"` + Site string `json:"site"` } func (s *Server) me(w http.ResponseWriter, r *http.Request) { @@ -203,6 +208,8 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) { if info, err := s.verifyInfo(r.Context()); err == nil { v.Verify = info.Tables } + v.PlaytimeStaff = s.ptStaff(r.Context(), id) } + v.Playtime = s.ptState(r.Context()) writeJSON(w, http.StatusOK, v) } diff --git a/internal/web/playtime.go b/internal/web/playtime.go new file mode 100644 index 0000000..46f2c46 --- /dev/null +++ b/internal/web/playtime.go @@ -0,0 +1,354 @@ +package web + +import ( + "context" + "log/slog" + "net/http" + "slices" + "strings" + "sync" + "time" + + "git.zio.sh/cs2/simpleadmin-web/internal/live" + "git.zio.sh/cs2/simpleadmin-web/internal/playtime" + "git.zio.sh/cs2/simpleadmin-web/internal/steam" +) + +// PlaytimeHistory (plugins/PlaytimeHistory in the cs2 repo) records who played when, in its own +// database (SAW_PT_DSN). Without that database, or its tables, nothing about playtime shows +// anywhere. With tables but nothing recorded yet, only staff see that it's there. +// +// Anyone sees playtime, stats and maps. Staff with playtimeStaffPerm also see who's on with their +// location, staff activity, disconnect reasons, countries, and a player's names, sessions and ping: +// on the staff panel's Playtime page, and on the public site when they're signed in. + +const playtimePlugin = "PlaytimeHistory" + +// playtimeStaffPerm matches PlaytimeHistory's own default for looking up other players. +const playtimeStaffPerm = "@css/generic" + +// Playtime states for /api/me. +const ( + ptOff = "" // not configured, the tables are missing, or the database is down + ptEmpty = "empty" // the tables are there, but nothing's recorded yet + ptOn = "on" +) + +type ptCache struct { + pt *playtime.Store + ttl time.Duration + + mu sync.Mutex + state string + fetched time.Time + logged string // the last state or error logged, so a down database logs once + overviews map[string]ptOverview +} + +type ptOverview struct { + at time.Time + view playtime.Overview +} + +func (c *ptCache) get(ctx context.Context) string { + if c == nil { + return ptOff + } + c.mu.Lock() + defer c.mu.Unlock() + if !c.fetched.IsZero() && time.Since(c.fetched) < c.ttl { + return c.state + } + tables, data, err := c.pt.State(ctx) + state, note := ptOff, "" + switch { + case err != nil: + note = "PlaytimeHistory's database isn't answering; playtime is hidden: " + err.Error() + case !tables: + note = "PlaytimeHistory's tables aren't in its database; playtime is hidden" + case !data: + state, note = ptEmpty, "PlaytimeHistory hasn't recorded anything yet" + default: + state, note = ptOn, "PlaytimeHistory found" + } + if note != c.logged { + slog.Info("playtime", "state", state, "note", note) + c.logged = note + } + c.state, c.fetched = state, time.Now() + return state +} + +// overview caches the public page per time zone for a minute: it's the heaviest query here. +func (c *ptCache) overview(ctx context.Context, loc *time.Location) (playtime.Overview, error) { + c.mu.Lock() + if o, ok := c.overviews[loc.String()]; ok && time.Since(o.at) < c.ttl { + c.mu.Unlock() + return o.view, nil + } + c.mu.Unlock() + v, err := c.pt.Overview(ctx, time.Now(), loc) + if err != nil { + return v, err + } + c.mu.Lock() + if c.overviews == nil || len(c.overviews) > 64 { + c.overviews = map[string]ptOverview{} + } + c.overviews[loc.String()] = ptOverview{at: time.Now(), view: v} + c.mu.Unlock() + return v, nil +} + +func (s *Server) ptState(ctx context.Context) string { return s.ptc.get(ctx) } + +// ptStaff is whether this viewer may see the staff-only playtime figures. +func (s *Server) ptStaff(ctx context.Context, id *Identity) bool { + return id != nil && id.Has(playtimeStaffPerm) && s.ptState(ctx) != ptOff +} + +// viewerZone is the browser's time zone (?tz=Europe/London), for days and hours of the day. +func viewerZone(r *http.Request) *time.Location { + name := r.URL.Query().Get("tz") + if name == "" || len(name) > 64 || strings.Contains(name, "..") { + return time.UTC + } + loc, err := time.LoadLocation(name) + if err != nil { + return time.UTC + } + return loc +} + +// periodSince reads ?period=7d|30d|all; all is nil. +func periodSince(r *http.Request, allowAll bool) (string, *time.Time) { + now := time.Now() + switch p := r.URL.Query().Get("period"); { + case p == "30d": + t := now.Add(-30 * 24 * time.Hour) + return p, &t + case p == "all" && allowAll: + return p, nil + default: + t := now.Add(-7 * 24 * time.Hour) + return "7d", &t + } +} + +type leaderView struct { + playtime.Leader + Rank *rankView `json:"rank,omitempty"` +} + +// publicPlaytime is the public Playtime page: ?period= for the leaderboard, ?tz= for the charts. +func (s *Server) publicPlaytime(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if s.ptState(ctx) != ptOn { + fail(w, http.StatusNotFound, "Playtime isn't being recorded.") + return + } + o, err := s.ptc.overview(ctx, viewerZone(r)) + if err != nil { + internal(w, r, err) + return + } + period, since := periodSince(r, true) + leaders, err := s.pt.Leaders(ctx, since, 10) + if err != nil { + internal(w, r, err) + return + } + x, err := s.staffIdx(ctx) + if err != nil { + internal(w, r, err) + return + } + lv := make([]leaderView, 0, len(leaders)) + for _, l := range leaders { + lv = append(lv, leaderView{Leader: l, Rank: s.rankOf(x, l.SteamID)}) + } + writeJSON(w, http.StatusOK, map[string]any{"overview": o, "period": period, "leaders": lv}) +} + +type onlineView struct { + SteamID string `json:"steamid"` + Name string `json:"name"` + Rank *rankView `json:"rank,omitempty"` + Team int `json:"team"` + Ping int `json:"ping"` + playtime.Online +} + +type activityView struct { + SteamID string `json:"steamid"` + Name string `json:"name"` + Rank *rankView `json:"rank,omitempty"` + Online bool `json:"online"` + playtime.Activity +} + +// adminPlaytime is the staff-only half of the Playtime page (?period=7d|30d for staff activity). +// The public half comes from publicPlaytime. +func (s *Server) adminPlaytime(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { + ctx := r.Context() + if !id.Has(playtimeStaffPerm) { + fail(w, http.StatusForbidden, "You need "+playtimeStaffPerm+" to see staff playtime.") + return + } + state := s.ptState(ctx) + if state == ptOff { + fail(w, http.StatusNotFound, "The panel can't reach PlaytimeHistory's database, or its tables aren't there.") + return + } + now := time.Now() + month := now.Add(-30 * 24 * time.Hour) + st := s.poll.Current() + + // Who's on comes from the live status, which leaves out admins hidden with css_hide. + humans := st.Humans() + ids := make([]uint64, 0, len(humans)) + for _, p := range humans { + if n, err := playtime.ParseID(p.SteamID); err == nil { + ids = append(ids, n) + } + } + open, err := s.pt.Online(ctx, ids) + if err != nil { + internal(w, r, err) + return + } + online := make([]onlineView, 0, len(humans)) + for _, p := range humans { + online = append(online, onlineView{SteamID: p.SteamID, Name: p.Name, Rank: s.rankOf(x, p.SteamID), + Team: p.Team, Ping: p.Ping, Online: open[p.SteamID]}) + } + // Longest on first; anyone without a recorded session last. + slices.SortStableFunc(online, func(a, b onlineView) int { + switch { + case a.Since == nil || b.Since == nil: + return boolInt(a.Since == nil) - boolInt(b.Since == nil) + default: + return a.Since.Compare(*b.Since) + } + }) + + period, since := periodSince(r, false) + var staffIDs []uint64 + for _, a := range x.admins { + if n, err := playtime.ParseID(a.SteamID); err == nil && s.isStaff(x, a.SteamID) { + staffIDs = append(staffIDs, n) + } + } + act, err := s.pt.Activity(ctx, staffIDs, *since) + if err != nil { + internal(w, r, err) + return + } + staffList := []activityView{} + for _, a := range x.admins { + if !s.isStaff(x, a.SteamID) { + continue + } + _, on := st.Find(a.SteamID) + staffList = append(staffList, activityView{SteamID: a.SteamID, Name: a.Name, Rank: s.rankOf(x, a.SteamID), + Online: on, Activity: act[a.SteamID]}) + } + slices.SortStableFunc(staffList, func(a, b activityView) int { + if a.Hours != b.Hours { + if a.Hours > b.Hours { + return -1 + } + return 1 + } + return x.groupIndex(x.primaryGroup(x.bySteam[a.SteamID])) - x.groupIndex(x.primaryGroup(x.bySteam[b.SteamID])) + }) + + reasons, err := s.pt.Reasons(ctx, month) + if err != nil { + internal(w, r, err) + return + } + countries, err := s.pt.Countries(ctx) + if err != nil { + internal(w, r, err) + return + } + ends, err := s.pt.EndKinds(ctx, month) + if err != nil { + internal(w, r, err) + return + } + resp := map[string]any{ + "state": state, "period": period, "online": online, "staff": staffList, + "reasons": reasons, "countries": countries, + "endKinds": map[string]int{ + "open": ends[playtime.EndOpen], "disconnect": ends[playtime.EndDisconnect], "mapChange": ends[playtime.EndMapChange], + "unload": ends[playtime.EndUnload], "stale": ends[playtime.EndStale], + }, + } + // The recorder's own status can carry database errors, so it's for Root, as css_pt_status is. + if id.Has("@css/root") { + resp["recorder"] = s.ptRecorder() + } + writeJSON(w, http.StatusOK, resp) +} + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} + +// ptRecorder is whether PlaytimeHistory is loaded and, if it is, what css_pt_status says. +func (s *Server) ptRecorder() map[string]any { + state, version := s.poll.Plugin(playtimePlugin) + out := map[string]any{"plugin": state, "version": version, "lines": []string{}} + if state != live.PluginLoaded { + return out + } + res, err := s.rc.Exec("css_pt_status") + if err != nil { + return out + } + var lines []string + for _, l := range strings.Split(res, "\n") { + if l = strings.TrimSpace(l); strings.HasPrefix(l, "[Playtime] ") { + lines = append(lines, strings.TrimPrefix(l, "[Playtime] ")) + } + } + if lines != nil { + out["lines"] = lines + } + return out +} + +// playerPlaytime is the Playtime part of a player's drawer, or nil when playtime isn't shown. +// Errors are logged and leave it out: playtime is extra, never worth failing the drawer. +func (s *Server) playerPlaytime(r *http.Request, steamid string, staff bool) map[string]any { + ctx := r.Context() + state := s.ptState(ctx) + if state == ptOff || (state == ptEmpty && !staff) || !steam.ValidID(steamid) { + return nil + } + n, err := playtime.ParseID(steamid) + if err != nil { + return nil + } + now := time.Now() + p, err := s.pt.Player(ctx, n, now, viewerZone(r)) + if err != nil { + slog.Warn("playtime for a player", "steamid", steamid, "err", err) + return nil + } + out := map[string]any{"player": p} + if staff { + extra, err := s.pt.PlayerStaff(ctx, n, now) + if err != nil { + slog.Warn("staff playtime for a player", "steamid", steamid, "err", err) + return out + } + out["staff"] = extra + } + return out +} diff --git a/internal/web/public.go b/internal/web/public.go index b2109d9..1ec507c 100644 --- a/internal/web/public.go +++ b/internal/web/public.go @@ -292,8 +292,18 @@ func (s *Server) publicPlayer(w http.ResponseWriter, r *http.Request) { _, online := s.poll.Current().Find(steamid) // Only whether they're verified: why, and overrides' expiry and who gave them, are for staff. st, verified := s.verifiedSet(r.Context(), []string{steamid})[steamid] - writeJSON(w, http.StatusOK, map[string]any{ + resp := map[string]any{ "steamid": steamid, "name": d.Name, "online": online, "rank": s.rankOf(x, steamid), "verified": verified && st.Verified(), "bans": toPublic(bans), "comms": toPublic(comms), - }) + } + // Signed-in staff get the staff playtime figures here too, as on the staff panel. + id, _, err := s.identity(r) + if err != nil { + internal(w, r, err) + return + } + if pt := s.playerPlaytime(r, steamid, s.ptStaff(r.Context(), id)); pt != nil { + resp["playtime"] = pt + } + writeJSON(w, http.StatusOK, resp) } diff --git a/internal/web/server.go b/internal/web/server.go index 5e6ab93..bbf15b5 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -14,6 +14,7 @@ import ( "time" "git.zio.sh/cs2/simpleadmin-web/internal/live" + "git.zio.sh/cs2/simpleadmin-web/internal/playtime" "git.zio.sh/cs2/simpleadmin-web/internal/push" "git.zio.sh/cs2/simpleadmin-web/internal/rcon" "git.zio.sh/cs2/simpleadmin-web/internal/saconfig" @@ -38,6 +39,8 @@ type Config struct { AndroidCerts []string // Push sends staff notifications; nil turns them off. Push *push.Sender + // Playtime reads PlaytimeHistory's database; nil turns playtime off. + Playtime *playtime.Store } type Server struct { @@ -49,6 +52,8 @@ type Server struct { sacfg *saconfig.File // nil when no config path is set staff *staffCache verify *verifyCache + pt *playtime.Store // nil without SAW_PT_DSN + ptc *ptCache // nil without SAW_PT_DSN avatars *avatarCache http *http.Client secure bool @@ -63,6 +68,10 @@ func New(cfg Config, st *store.Store, rc *rcon.Client, poll *live.Poller, sacfg if err != nil || u.Host == "" { return nil, errors.New("base URL must be absolute, like https://bans.example.com") } + var ptc *ptCache + if cfg.Playtime != nil { + ptc = &ptCache{pt: cfg.Playtime, ttl: time.Minute} + } return &Server{ cfg: cfg, site: site, @@ -72,6 +81,8 @@ func New(cfg Config, st *store.Store, rc *rcon.Client, poll *live.Poller, sacfg sacfg: sacfg, staff: &staffCache{st: st, ttl: 15 * time.Second}, verify: &verifyCache{st: st, ttl: 30 * time.Second}, + pt: cfg.Playtime, + ptc: ptc, avatars: newAvatarCache(&http.Client{Timeout: 10 * time.Second}), http: &http.Client{Timeout: 10 * time.Second}, secure: u.Scheme == "https", @@ -107,6 +118,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/public/staff", s.publicStaff) mux.HandleFunc("GET /api/public/ranks", s.publicRankChanges) mux.HandleFunc("GET /api/public/players/{steamid}", s.publicPlayer) + mux.HandleFunc("GET /api/public/playtime", s.publicPlaytime) mux.HandleFunc("GET /avatar/{steamid}", s.avatar) admin := func(pattern string, h adminHandler) { mux.HandleFunc(pattern, s.requireStaff(h)) } @@ -118,6 +130,7 @@ func (s *Server) Handler() http.Handler { admin("GET /api/admin/staff", s.adminStaff) admin("GET /api/admin/settings", s.adminSettings) admin("GET /api/admin/verify", s.adminVerify) + admin("GET /api/admin/playtime", s.adminPlaytime) admin("POST /api/admin/penalties", s.addPenalty) admin("POST /api/admin/penalties/lift", s.liftPenalty) diff --git a/main.go b/main.go index 5914170..56b7a24 100644 --- a/main.go +++ b/main.go @@ -22,6 +22,7 @@ import ( "github.com/go-sql-driver/mysql" "git.zio.sh/cs2/simpleadmin-web/internal/live" + "git.zio.sh/cs2/simpleadmin-web/internal/playtime" "git.zio.sh/cs2/simpleadmin-web/internal/push" "git.zio.sh/cs2/simpleadmin-web/internal/rcon" "git.zio.sh/cs2/simpleadmin-web/internal/saconfig" @@ -158,6 +159,14 @@ func run() error { poller := live.NewPoller(rc, time.Duration(max(pollSecs, 2))*time.Second) go poller.Run(ctx) + pt, err := openPlaytime() + if err != nil { + return err + } + if pt != nil { + defer pt.Close() + } + joinAddr := env("SAW_JOIN_ADDRESS", srv.Address) app, err := web.New(web.Config{ BaseURL: baseURL, @@ -169,6 +178,7 @@ func run() error { AndroidPackage: env("SAW_ANDROID_PACKAGE", ""), AndroidCerts: strings.Split(env("SAW_ANDROID_CERT_SHA256", ""), ","), Push: pushSender(ctx, st, baseURL), + Playtime: pt, }, st, rc, poller, sacfg) if err != nil { return err @@ -222,6 +232,24 @@ func pushSender(ctx context.Context, st *store.Store, baseURL string) *push.Send return &push.Sender{Keys: keys, Subject: baseURL, Client: &http.Client{Timeout: 15 * time.Second}} } +// openPlaytime opens PlaytimeHistory's database (SAW_PT_DSN), or returns nil when it isn't set. +// It doesn't connect yet: if that database is down, playtime is hidden until it's back. +func openPlaytime() (*playtime.Store, error) { + dsn := env("SAW_PT_DSN", "") + if dsn == "" { + return nil, nil + } + server, err := envInt("SAW_PT_SERVER_ID", 0) + if err != nil { + return nil, err + } + pt, err := playtime.Open(dsn, server) + if err != nil { + return nil, fmt.Errorf("SAW_PT_DSN: %w", err) + } + return pt, nil +} + // openDB connects with SAW_DB_DSN, or with the DatabaseConfig in CS2-SimpleAdmin.json. func openDB(sacfg *saconfig.File) (*sql.DB, error) { var cfg *mysql.Config