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.
This commit is contained in:
Astra 2026-10-04 21:05:57 +01:00
parent daa7ec2dbc
commit 6d0fc6e463
18 changed files with 2238 additions and 12 deletions

View file

@ -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). 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 - **Verification** (optional): who our GroupVerify plugin verifies, with overrides to give and
remove, and its settings. Only appears when GroupVerify is installed (see below). 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 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. 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` | | Raw console commands (Server settings > Console; sent without waiting, output only logged) | `@css/rcon` |
| See the Staff & ranks page | `@zio/webpanel` | | See the Staff & ranks page | `@zio/webpanel` |
| Give and remove verification overrides | `@zio/verify` (not on themselves, unless Root) | | 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` | | 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, `@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 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. 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'@'<panel host>' IDENTIFIED BY '<password>';
GRANT SELECT ON playtime_history.* TO 'pt_web'@'<panel host>';
```
```
SAW_PT_DSN=pt_web:<password>@tcp(<db-host>: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) ### Name history (optional)
SimpleAdmin keeps one row per SteamID + IP in `sa_players_ips` and overwrites its name on each 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 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, `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 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. its ban, mute, admin and group tables and the panel's push tables.

View file

@ -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): # 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 # 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. # RCON. By default the address and password come from SimpleAdmin's sa_servers table.
# SAW_RCON_ADDR=127.0.0.1:27015 # SAW_RCON_ADDR=127.0.0.1:27015
# SAW_RCON_PASSWORD= # SAW_RCON_PASSWORD=

View file

@ -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()
}

View file

@ -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")
}

View file

@ -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)
}
}
}

141
internal/playtime/testdata/schema.sql vendored Normal file
View file

@ -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;

View file

@ -245,6 +245,9 @@ func (s *Server) adminPlayer(w http.ResponseWriter, r *http.Request, id *Identit
} else if v != nil { } else if v != nil {
resp["verify"] = v resp["verify"] = v
} }
if pt := s.playerPlaytime(r, steamid, s.ptStaff(ctx, id)); pt != nil {
resp["playtime"] = pt
}
writeJSON(w, http.StatusOK, resp) writeJSON(w, http.StatusOK, resp)
} }

View file

@ -56,6 +56,7 @@
<div id="toast-root" aria-live="polite"></div> <div id="toast-root" aria-live="polite"></div>
<script src="/static/common.js"></script> <script src="/static/common.js"></script>
<script src="/static/playtime.js"></script>
<script src="/static/admin.js"></script> <script src="/static/admin.js"></script>
</body> </body>
</html> </html>

View file

@ -6,6 +6,8 @@ const PAGES = [
{ id: "players", label: "Players" }, { id: "players", label: "Players" },
{ id: "bans", label: "Bans" }, { id: "bans", label: "Bans" },
{ id: "comms", label: "Gags & mutes" }, { 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" }, { id: "staff", label: "Staff & ranks", flag: "@zio/webpanel" },
// Only when GroupVerify's tables are in the database. // Only when GroupVerify's tables are in the database.
{ id: "verify", label: "Verification", show: () => !!state.me?.verify }, { id: "verify", label: "Verification", show: () => !!state.me?.verify },
@ -798,6 +800,7 @@ async function openPlayer(sid) {
</dl> </dl>
</section> </section>
${verifySection(d.verify, p.steamid, name)} ${verifySection(d.verify, p.steamid, name)}
${ptDrawer(d.playtime, d.online)}
${manage ? `<section> ${manage ? `<section>
<h3>Rank</h3> <h3>Rank</h3>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
@ -941,7 +944,9 @@ async function act(path, method, body) {
/* ---------- Routing & events ---------- */ /* ---------- 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() { function renderNav() {
const c = state.counts; const c = state.counts;

View file

@ -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: '<span class="faint">Unassigned</span>', 1: '<span class="muted">Spectating</span>', 2: '<span class="team t">T</span>', 3: '<span class="team ct">CT</span>' };
const PT_DBIP = '<p class="pt-credit">Locations from <a href="https://db-ip.com" target="_blank" rel="noopener">IP Geolocation by DB-IP</a> (CC BY 4.0). The plugin never stores IP addresses.</p>';
// 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 `<div class="who pt-who">${avatar(p.steamid, p.name)}<div><b>${esc(p.name || "Unknown")}${p.rank ? rankTag(p.rank) : ""}</b><span class="sid">${esc(p.steamid)}</span></div></div>`;
}
function ptBar(value, max, label, fill) {
return `<div class="pt-hbar"><div class="track"><div class="fill" style="width:${ptPct(value, max)}%${fill ? `;--fill:${fill}` : ""}"></div></div><span>${label}</span></div>`;
}
// 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(`<div class="grid" style="bottom:${(g / max) * 100}%"><span>${g}</span></div>`);
}
return `<div class="pt-cols">
<div class="plot">${grid.join("")}${values.map((x) => `<div class="bar" tabindex="0" data-tip="${esc(x.tip)}"><i style="height:${x.v ? Math.max(1, (x.v / max) * 100) : 0}%${fill ? `;background:${fill}` : ""}"></i></div>`).join("")}</div>
<div class="ticks">${values.map((x) => `<span>${x.tick ?? ""}</span>`).join("")}</div>
${note ? `<p class="note">${note}</p>` : ""}
</div>`;
}
function ptSplit(t, ct, spec) {
const total = t + ct + spec;
const seg = (cls, v) => (v > 0 ? `<i class="${cls}" style="flex:${v}" data-tip="${esc(`${ptH(v)} (${ptPct(v, total)}%)`)}"></i>` : "");
return `<div class="pt-split" role="img" aria-label="T ${ptH(t)}, CT ${ptH(ct)}, spectating ${ptH(spec)}">${seg("t", t)}${seg("ct", ct)}${seg("spec", spec)}</div>
<ul class="pt-legend">
<li><i style="--c:var(--t)"></i>T <span>${ptH(t)}</span></li>
<li><i style="--c:var(--ct)"></i>CT <span>${ptH(ct)}</span></li>
<li><i style="--c:var(--spec)"></i>Spectating <span>${ptH(spec)}</span></li>
</ul>`;
}
/* ---------- Page: public panels ---------- */
function ptTiles(s) {
const delta = (a, b) => (b ? `<span class="delta ${a >= b ? "up" : ""}">${a >= b ? "+" : ""}${ptPct(a - b, b)}% on the 7 days before</span>` : '<span class="delta">Nothing the 7 days before</span>');
return `<div class="pt-tiles">
<div class="pt-tile"><small>Players, last 7 days</small><b>${s.players}</b>${delta(s.players, s.playersPrev)}</div>
<div class="pt-tile"><small>Hours played, last 7 days</small><b>${Math.round(s.hours).toLocaleString()}</b>${delta(s.hours, s.hoursPrev)}</div>
<div class="pt-tile"><small>Most online at once</small><b>${s.peak}</b><span class="delta">${s.peakAt ? fmtDate(s.peakAt) : "No count yet"}</span></div>
<div class="pt-tile"><small>New players, last 7 days</small><b>${s.newPlayers}</b><span class="delta">First time on the server</span></div>
</div>`;
}
function ptLeaders(d) {
const max = Math.max(0.1, ...d.leaders.map((p) => p.t + p.ct));
return `<section class="panel">
<div class="panel-head">
<div><h2>Top players</h2><p>Time on T or CT. Spectating doesn't count.</p></div>
${segmented("ptPeriod", d.period, PT_PERIODS)}
</div>
${d.leaders.length ? `<div class="table-wrap"><table>
<thead><tr><th style="width:3em">#</th><th>Player</th><th>Time played</th><th class="num hide-sm">T / CT</th><th class="num">K/D</th></tr></thead>
<tbody>${d.leaders.map((p, i) => `<tr class="clickable" data-player="${esc(p.steamid)}">
<td><span class="pt-place">${i + 1}</span></td>
<td>${ptWho(p)}</td>
<td>${ptBar(p.t + p.ct, max, ptH(p.t + p.ct))}</td>
<td class="num hide-sm muted">${p.t.toFixed(1)} / ${p.ct.toFixed(1)}</td>
<td class="num">${ptKD(p.kills, p.deaths)}</td>
</tr>`).join("")}</tbody>
</table></div>` : '<div class="empty">Nobody has played on a team in this time.</div>'}
</section>`;
}
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 `<section class="panel">
<div class="panel-head"><div><h2>Busiest hours</h2><p>Players on T or CT, averaged over the last 30 days, in your time zone.</p></div></div>
${ptColumns(vals, { note: o.byHour[best] ? `Busiest at <b>${ptPad(best)}:00</b>, with ${o.byHour[best]} players on average.` : "" })}
</section>`;
}
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 `<section class="panel">
<div class="panel-head"><div><h2>Players per day</h2><p>Different players who joined each day, last 30 days.</p></div></div>
${ptColumns(vals, { fill: "var(--ct)", whole: true })}
</section>`;
}
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 `<section class="panel">
<div class="panel-head"><div><h2>When people play</h2><p>Average players on T or CT by day and hour, last 4 weeks, in your time zone.</p></div></div>
<div class="pt-heat"><table>
<thead><tr><th></th>${o.heat[0].map((_, h) => `<th>${h % 3 === 0 ? ptPad(h) : ""}</th>`).join("")}</tr></thead>
<tbody>${o.heat.map((row, wd) => `<tr><th scope="row">${PT_WEEKDAYS[wd]}</th>${row.map((v, h) => `<td><i style="background:var(--heat-${step(v)})" data-tip="${PT_WEEKDAYS[wd]} ${ptPad(h)}:00 · ${v} players"></i></td>`).join("")}</tr>`).join("")}</tbody>
</table>
<div class="scale">Fewer ${[0, 1, 2, 3, 4, 5].map((n) => `<i style="background:var(--heat-${n})"></i>`).join("")} More</div></div>
</section>`;
}
function ptMapsPanel(o) {
const max = Math.max(0.1, ...o.maps.map((m) => m.hours));
return `<section class="panel">
<div class="panel-head"><div><h2>Maps</h2><p>Hours played on each map, last 30 days.</p></div></div>
${o.maps.length ? `<div class="table-wrap"><table>
<thead><tr><th>Map</th><th>Hours played</th><th class="num">Players</th></tr></thead>
<tbody>${o.maps.map((m) => `<tr><td><b>${esc(m.map)}</b></td><td>${ptBar(m.hours, max, m.hours.toLocaleString(), "var(--t)")}</td><td class="num muted">${m.players}</td></tr>`).join("")}</tbody>
</table></div>` : '<div class="empty">No maps played in the last 30 days.</div>'}
</section>`;
}
function ptPublicPanels(d) {
const o = d.overview;
return `${ptTiles(o.summary)}<div id="pt-leaders">${ptLeaders(d)}</div>
<div class="pt-grid">${ptBusiest(o)}${ptDaily(o)}</div>${ptHeatmap(o)}${ptMapsPanel(o)}`;
}
/* ---------- Page: staff panels ---------- */
function ptOnline(a) {
return `<section class="panel">
<div class="panel-head"><div><h2>On the server now</h2><p>How long each player has been on, their team and where they're connecting from.</p></div></div>
${a.online.length ? `<div class="table-wrap"><table>
<thead><tr><th>Player</th><th>Online for</th><th>Team</th><th class="num">Ping</th><th class="hide-sm">From</th></tr></thead>
<tbody>${a.online.map((p) => `<tr class="clickable" data-player="${esc(p.steamid)}">
<td>${ptWho(p)}</td>
<td>${p.since ? ptDur((Date.now() - new Date(p.since)) / 1000) : '<span class="faint">Not recorded</span>'}</td>
<td>${PT_TEAM[p.team] || ""}${p.alive === false ? ' <span class="faint">dead</span>' : ""}</td>
<td class="num">${p.ping > 100 ? `<span class="tag amber">${p.ping}</span>` : `<span class="muted">${p.ping}</span>`}</td>
<td class="hide-sm">${p.country ? `${p.city ? `${esc(p.city)}, ` : ""}${esc(p.country)}` : '<span class="faint">Unknown</span>'}</td>
</tr>`).join("")}</tbody>
</table></div>${PT_DBIP}` : '<div class="empty">Nobody is on the server.</div>'}
</section>`;
}
function ptActivity(a) {
const max = Math.max(0.1, ...a.staff.map((s) => s.hours));
return `<section class="panel">
<div class="panel-head"><div><h2>Staff activity</h2><p>Time each staff member spent on the server.</p></div>
${segmented("ptStaffPeriod", a.period, PT_PERIODS.slice(0, 2))}</div>
${a.staff.length ? `<div class="table-wrap"><table>
<thead><tr><th>Staff</th><th>On T or CT</th><th class="num hide-sm">Spectating</th><th>Last seen</th></tr></thead>
<tbody>${a.staff.map((s) => `<tr class="clickable" data-player="${esc(s.steamid)}">
<td>${ptWho(s)}</td>
<td>${s.hours ? ptBar(s.hours, max, ptH(s.hours), "var(--green)") : '<span class="tag amber">Not on a team</span>'}</td>
<td class="num hide-sm muted">${s.spec ? ptH(s.spec) : "–"}</td>
<td>${s.online ? '<span class="dot"></span>Online now' : s.lastSeen ? fmtAgo(s.lastSeen) : '<span class="faint">Never</span>'}</td>
</tr>`).join("")}</tbody>
</table></div>` : '<div class="empty">No staff yet.</div>'}
</section>`;
}
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 `<section class="panel">
<div class="panel-head"><div><h2>Why people leave</h2><p>Disconnect reasons, last 30 days. Lots of timeouts can mean a network problem.</p></div></div>
${a.reasons.length ? `<div class="table-wrap"><table>
<thead><tr><th>Reason</th><th>Disconnects</th><th class="num hide-sm">Stayed</th></tr></thead>
<tbody>${a.reasons.map((r) => `<tr><td><b>${esc(PT_REASONS[r.name] || r.name || "Unknown")}</b>${PT_REASONS[r.name] ? `<span class="sid" style="display:block">${esc(r.name)}</span>` : ""}</td>
<td>${ptBar(r.count, max, `${r.count} · ${ptPct(r.count, total)}%`, "var(--amber)")}</td><td class="num hide-sm muted">${ptDur(r.avgSeconds)} avg.</td></tr>`).join("")}</tbody>
</table></div>` : '<div class="empty">No disconnects in the last 30 days.</div>'}
</section>`;
}
function ptCountries(a) {
const max = Math.max(1, ...a.countries.map((c) => c.players));
return `<section class="panel">
<div class="panel-head"><div><h2>Where players are from</h2><p>Different players per country, all time.</p></div></div>
${a.countries.length ? `<div class="table-wrap"><table>
<thead><tr><th>Country</th><th>Players</th><th class="num hide-sm">Hours</th></tr></thead>
<tbody>${a.countries.map((c) => `<tr><td><b>${esc(c.name || c.iso)}</b> <span class="faint">${esc(c.iso)}</span></td><td>${ptBar(c.players, max, c.players, "var(--ct)")}</td><td class="num hide-sm muted">${Math.round(c.hours).toLocaleString()}</td></tr>`).join("")}</tbody>
</table></div>${PT_DBIP}` : '<div class="empty">No locations recorded yet.</div>'}
</section>`;
}
function ptRecorder(a) {
const r = a.recorder;
if (!r) return "";
const e = a.endKinds;
const plugin = { loaded: `<span class="tag green">Loaded</span> <span class="muted">${esc(r.version)}</span>`, missing: '<span class="tag red">Not loaded</span>', unknown: '<span class="tag">Unknown</span> <span class="muted">the game server isn\'t answering</span>' }[r.plugin] || "";
return `<section class="panel">
<div class="panel-head"><div><h2>Recorder</h2><p>PlaytimeHistory on the game server (<code>css_pt_status</code>), and how sessions ended in the last 30 days. Only Root sees this.</p></div></div>
<div class="panel-body"><dl class="kv">
<dt>Plugin</dt><dd>${plugin}</dd>
${r.lines.length ? `<dt>Status</dt><dd><ul class="pt-lines">${r.lines.map((l) => `<li>${esc(l)}</li>`).join("")}</ul></dd>` : ""}
<dt>Sessions ended</dt><dd>${e.disconnect.toLocaleString()} by disconnect, ${e.mapChange.toLocaleString()} by map change, ${e.unload} by plugin unload</dd>
<dt>Crashes</dt><dd>${e.stale ? `<span class="tag amber">${e.stale} sessions</span> closed at their last heartbeat` : "None"}</dd>
</dl></div>
</section>`;
}
function ptStaffPanels(a) {
return `<div class="stack pt-stack">${ptOnline(a)}<div id="pt-activity">${ptActivity(a)}</div>${ptReasons(a)}${ptCountries(a)}${ptRecorder(a)}</div>`;
}
/* ---------- 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 = `
<div class="page-head"><div><h1>Playtime</h1><p class="sub">${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."}</p></div></div>
${staff ? `<div id="pt-staff">${loading()}</div><h2 class="crew-sep">${me.playtime === "on" ? "On the public site" : ""}</h2>` : ""}
<div id="pt-public" class="stack pt-stack">${me?.playtime === "on" ? loading() : ""}</div>`;
if (me?.playtime !== "on") {
$("#pt-public").innerHTML = '<div class="empty">PlaytimeHistory hasn\'t recorded anything yet. Playtime appears on the public site once it has.</div>';
}
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 = `<section><h3>Playtime</h3><p class="muted" style="margin:0">PlaytimeHistory hasn't recorded this player.</p></section>`;
} 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 = `<section>
<h3>Playtime</h3>
<dl class="kv" style="margin-bottom:14px">
<dt>Time played</dt><dd><b>${ptH(played)}</b> on T or CT</dd>
<dt>First seen</dt><dd>${fmtDate(p.firstSeen)}</dd>
<dt>Last seen</dt><dd>${online ? '<span class="dot"></span>On the server now' : fmtAgo(p.lastSeen)}</dd>
<dt>Visits</dt><dd>${p.sessions.toLocaleString()} ${p.sessions === 1 ? "session" : "sessions"}</dd>
</dl>
${played + p.spec > 0 ? ptSplit(p.t, p.ct, p.spec) : ""}
${p.alive + p.dead > 0 ? `<p class="muted" style="margin:10px 0 0;font-size:14px">Alive for ${ptPct(p.alive, p.alive + p.dead)}% of their time on a team.</p>` : ""}
</section>
<section>
<h3>Last 30 days</h3>
<div class="pt-spark" role="img" aria-label="Hours played each day">${p.days.map((d) => `<i class="${d.hours ? "" : "zero"}" style="height:${d.hours ? Math.max(6, (d.hours / maxDay) * 100) : 4}%" data-tip="${esc(`${ptDay(d.date)} · ${d.hours ? ptH(d.hours) : "didn't play"}`)}"></i>`).join("")}</div>
</section>
${st.kills + st.deaths > 0 ? `<section>
<h3>Stats</h3>
<div class="pt-stats">
<div><b>${ptKD(st.kills, st.deaths)}</b><small>K/D</small></div>
<div><b>${ptPct(st.headshots, st.kills)}%</b><small>Headshot kills</small></div>
<div><b>${st.rounds ? Math.round(st.damage / st.rounds) : "–"}</b><small>Damage per round</small></div>
<div><b>${st.mvps}</b><small>MVPs</small></div>
</div>
<p class="muted" style="margin:10px 0 0;font-size:14px">${st.kills.toLocaleString()} kills, ${st.deaths.toLocaleString()} deaths and ${st.assists.toLocaleString()} assists over ${st.rounds.toLocaleString()} rounds.</p>
</section>` : ""}
${p.maps.length ? `<section>
<h3>Favourite maps</h3>
<div class="pt-maps">${p.maps.map((m) => `<div><span>${esc(m.map)}</span>${ptBar(m.hours, maxMap, ptH(m.hours), "var(--t)")}</div>`).join("")}</div>
</section>` : ""}`;
}
const s = pt.staff;
if (!s) return out;
const reason = (x) => {
if (x.endKind === 0) return '<span class="tag green">On now</span>';
if (x.endKind === 2) return "Map change";
if (x.endKind === 3) return "Plugin unloaded";
if (x.endKind === 4) return '<span class="tag amber">Server crashed</span>';
return esc(PT_REASONS[x.reason] || x.reason || "Left");
};
return `${out}
${s.names.length ? `<section>
<h3>Names used</h3>
<ul class="pt-names">${s.names.map((n) => `<li><span><b>${esc(n.name)}</b> <span class="faint">×${n.times}</span></span><span class="muted">${fmtDate(n.firstSeen)} – ${fmtDate(n.lastSeen)}</span></li>`).join("")}</ul>
</section>` : ""}
${s.recent.length ? `<section>
<h3>Recent sessions</h3>
<ul class="pt-sessions">${s.recent.map((x) => `<li>
<div class="top"><b>${fmtDate(x.started)}</b><span>${ptDur(x.seconds)}</span></div>
<div class="muted">${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` : ""}</div>
<div>${reason(x)}</div>
</li>`).join("")}</ul>
</section>` : ""}
${s.country || s.ping != null ? `<section>
<h3>Connection</h3>
<dl class="kv">
${s.country ? `<dt>Location</dt><dd>${s.city ? `${esc(s.city)}, ` : ""}${esc(s.countryName || s.country)}</dd>` : ""}
${s.ping != null ? `<dt>Average ping</dt><dd>${s.ping} ms <span class="muted">last 30 days</span></dd>` : ""}
</dl>
</section>${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; });

View file

@ -34,6 +34,7 @@
<div id="toast-root" aria-live="polite"></div> <div id="toast-root" aria-live="polite"></div>
<script src="/static/common.js"></script> <script src="/static/common.js"></script>
<script src="/static/playtime.js"></script>
<script src="/static/public.js"></script> <script src="/static/public.js"></script>
</body> </body>
</html> </html>

View file

@ -1,14 +1,18 @@
// Public, read-only site: the live server, bans, gags/mutes, and staff with their rank changes. The // Public, read-only site: the live server, bans, gags/mutes, staff with their rank changes, and
// API leaves out who issued penalties and IPs; nothing here can change anything. // 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 = [ const PAGES = [
{ id: "server", label: "Server" }, { id: "server", label: "Server" },
{ id: "bans", label: "Bans" }, { id: "bans", label: "Bans" },
{ id: "comms", label: "Gags & mutes" }, { id: "comms", label: "Gags & mutes" },
{ id: "staff", label: "Staff" }, { 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) { function siteName(title) {
const i = title.indexOf("."); const i = title.indexOf(".");
@ -242,6 +246,7 @@ async function openPlayer(sid) {
${now.length || p.online || p.verified ? `<section> ${now.length || p.online || p.verified ? `<section>
<div class="chips">${p.verified ? VERIFIED_TAG : ""}${p.online ? '<span class="tag">On the server</span>' : ""}${now.map((h) => `<span class="tag ${h.kind === "ban" ? "red" : "amber"}">${h.kind === "ban" ? "Banned" : COMM_HELP[h.type]}${h.duration ? `, ${fmtLeft(served(h).left)}` : ", permanently"}</span>`).join("")}</div> <div class="chips">${p.verified ? VERIFIED_TAG : ""}${p.online ? '<span class="tag">On the server</span>' : ""}${now.map((h) => `<span class="tag ${h.kind === "ban" ? "red" : "amber"}">${h.kind === "ban" ? "Banned" : COMM_HELP[h.type]}${h.duration ? `, ${fmtLeft(served(h).left)}` : ", permanently"}</span>`).join("")}</div>
</section>` : ""} </section>` : ""}
${ptDrawer(p.playtime, p.online)}
<section> <section>
<h3>History</h3> <h3>History</h3>
${history.length ? `<ul class="history">${history.map((h) => `<li> ${history.length ? `<ul class="history">${history.map((h) => `<li>
@ -256,11 +261,20 @@ async function openPlayer(sid) {
/* ---------- Routing & events ---------- */ /* ---------- 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() { function route() {
const [page, query] = location.hash.slice(1).split("?"); 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=<steamid> (from a staff notification) opens the list searching for that // A link like #bans?q=<steamid> (from a staff notification) opens the list searching for that
// player, showing every status so lifted and expired ones are there too. // player, showing every status so lifted and expired ones are there too.
const q = new URLSearchParams(query || "").get("q"); const q = new URLSearchParams(query || "").get("q");
@ -269,7 +283,7 @@ function route() {
state.num = 1; state.num = 1;
state[state.page === "bans" ? "banFilter" : "commFilter"] = "all"; state[state.page === "bans" ? "banFilter" : "commFilter"] = "all";
} }
$("#nav-links").innerHTML = PAGES.map((p) => `<li><a href="#${p.id}" ${p.id === state.page ? 'aria-current="page"' : ""}>${p.label}</a></li>`).join(""); $("#nav-links").innerHTML = PAGES.filter(pageShown).map((p) => `<li><a href="#${p.id}" ${p.id === state.page ? 'aria-current="page"' : ""}>${p.label}</a></li>`).join("");
RENDER[state.page](); RENDER[state.page]();
} }
@ -283,6 +297,7 @@ document.addEventListener("input", (e) => {
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
const t = e.target; const t = e.target;
const seg = t.closest("[data-seg] button"); const seg = t.closest("[data-seg] button");
if (seg && seg.parentElement.dataset.seg.startsWith("pt")) return; // playtime.js
if (seg) { if (seg) {
state[seg.parentElement.dataset.seg] = seg.dataset.value; state[seg.parentElement.dataset.seg] = seg.dataset.value;
state.num = 1; state.num = 1;
@ -303,10 +318,15 @@ document.addEventListener("keydown", (e) => {
}); });
api("/api/me").then((me) => { api("/api/me").then((me) => {
state.me = me;
$("#site-name").innerHTML = siteName(me.site); $("#site-name").innerHTML = siteName(me.site);
document.title = `${me.site} bans and staff`; document.title = `${me.site} bans and staff`;
if (me.staff) $(".signin").innerHTML = "Staff panel"; 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) => `<li><a href="#${p.id}" ${p.id === state.page ? 'aria-current="page"' : ""}>${p.label}</a></li>`).join("");
});
// Keep the scoreboard current while it's on screen. // Keep the scoreboard current while it's on screen.
setInterval(() => { if (state.page === "server" && !document.hidden && !$("#drawer-root").innerHTML) server(); }, 15000); setInterval(() => { if (state.page === "server" && !document.hidden && !$("#drawer-root").innerHTML) server(); }, 15000);

View file

@ -1082,3 +1082,120 @@ a.btn { text-decoration: none; }
background: var(--accent-soft); background: var(--accent-soft);
font-size: 14px; 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; }
}

View file

@ -182,8 +182,13 @@ type meView struct {
Perms []string `json:"perms,omitempty"` Perms []string `json:"perms,omitempty"`
Immunity int `json:"immunity,omitempty"` Immunity int `json:"immunity,omitempty"`
// Verify is true when GroupVerify's tables are in the database, for the Verification page. // Verify is true when GroupVerify's tables are in the database, for the Verification page.
Verify bool `json:"verify,omitempty"` Verify bool `json:"verify,omitempty"`
Site string `json:"site"` // 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) { 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 { if info, err := s.verifyInfo(r.Context()); err == nil {
v.Verify = info.Tables v.Verify = info.Tables
} }
v.PlaytimeStaff = s.ptStaff(r.Context(), id)
} }
v.Playtime = s.ptState(r.Context())
writeJSON(w, http.StatusOK, v) writeJSON(w, http.StatusOK, v)
} }

354
internal/web/playtime.go Normal file
View file

@ -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
}

View file

@ -292,8 +292,18 @@ func (s *Server) publicPlayer(w http.ResponseWriter, r *http.Request) {
_, online := s.poll.Current().Find(steamid) _, online := s.poll.Current().Find(steamid)
// Only whether they're verified: why, and overrides' expiry and who gave them, are for staff. // 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] 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), "steamid": steamid, "name": d.Name, "online": online, "rank": s.rankOf(x, steamid),
"verified": verified && st.Verified(), "bans": toPublic(bans), "comms": toPublic(comms), "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)
} }

View file

@ -14,6 +14,7 @@ import (
"time" "time"
"git.zio.sh/cs2/simpleadmin-web/internal/live" "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/push"
"git.zio.sh/cs2/simpleadmin-web/internal/rcon" "git.zio.sh/cs2/simpleadmin-web/internal/rcon"
"git.zio.sh/cs2/simpleadmin-web/internal/saconfig" "git.zio.sh/cs2/simpleadmin-web/internal/saconfig"
@ -38,6 +39,8 @@ type Config struct {
AndroidCerts []string AndroidCerts []string
// Push sends staff notifications; nil turns them off. // Push sends staff notifications; nil turns them off.
Push *push.Sender Push *push.Sender
// Playtime reads PlaytimeHistory's database; nil turns playtime off.
Playtime *playtime.Store
} }
type Server struct { type Server struct {
@ -49,6 +52,8 @@ type Server struct {
sacfg *saconfig.File // nil when no config path is set sacfg *saconfig.File // nil when no config path is set
staff *staffCache staff *staffCache
verify *verifyCache verify *verifyCache
pt *playtime.Store // nil without SAW_PT_DSN
ptc *ptCache // nil without SAW_PT_DSN
avatars *avatarCache avatars *avatarCache
http *http.Client http *http.Client
secure bool 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 == "" { if err != nil || u.Host == "" {
return nil, errors.New("base URL must be absolute, like https://bans.example.com") 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{ return &Server{
cfg: cfg, cfg: cfg,
site: site, site: site,
@ -72,6 +81,8 @@ func New(cfg Config, st *store.Store, rc *rcon.Client, poll *live.Poller, sacfg
sacfg: sacfg, sacfg: sacfg,
staff: &staffCache{st: st, ttl: 15 * time.Second}, staff: &staffCache{st: st, ttl: 15 * time.Second},
verify: &verifyCache{st: st, ttl: 30 * time.Second}, verify: &verifyCache{st: st, ttl: 30 * time.Second},
pt: cfg.Playtime,
ptc: ptc,
avatars: newAvatarCache(&http.Client{Timeout: 10 * time.Second}), avatars: newAvatarCache(&http.Client{Timeout: 10 * time.Second}),
http: &http.Client{Timeout: 10 * time.Second}, http: &http.Client{Timeout: 10 * time.Second},
secure: u.Scheme == "https", 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/staff", s.publicStaff)
mux.HandleFunc("GET /api/public/ranks", s.publicRankChanges) mux.HandleFunc("GET /api/public/ranks", s.publicRankChanges)
mux.HandleFunc("GET /api/public/players/{steamid}", s.publicPlayer) mux.HandleFunc("GET /api/public/players/{steamid}", s.publicPlayer)
mux.HandleFunc("GET /api/public/playtime", s.publicPlaytime)
mux.HandleFunc("GET /avatar/{steamid}", s.avatar) mux.HandleFunc("GET /avatar/{steamid}", s.avatar)
admin := func(pattern string, h adminHandler) { mux.HandleFunc(pattern, s.requireStaff(h)) } 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/staff", s.adminStaff)
admin("GET /api/admin/settings", s.adminSettings) admin("GET /api/admin/settings", s.adminSettings)
admin("GET /api/admin/verify", s.adminVerify) 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", s.addPenalty)
admin("POST /api/admin/penalties/lift", s.liftPenalty) admin("POST /api/admin/penalties/lift", s.liftPenalty)

28
main.go
View file

@ -22,6 +22,7 @@ import (
"github.com/go-sql-driver/mysql" "github.com/go-sql-driver/mysql"
"git.zio.sh/cs2/simpleadmin-web/internal/live" "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/push"
"git.zio.sh/cs2/simpleadmin-web/internal/rcon" "git.zio.sh/cs2/simpleadmin-web/internal/rcon"
"git.zio.sh/cs2/simpleadmin-web/internal/saconfig" "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) poller := live.NewPoller(rc, time.Duration(max(pollSecs, 2))*time.Second)
go poller.Run(ctx) 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) joinAddr := env("SAW_JOIN_ADDRESS", srv.Address)
app, err := web.New(web.Config{ app, err := web.New(web.Config{
BaseURL: baseURL, BaseURL: baseURL,
@ -169,6 +178,7 @@ func run() error {
AndroidPackage: env("SAW_ANDROID_PACKAGE", ""), AndroidPackage: env("SAW_ANDROID_PACKAGE", ""),
AndroidCerts: strings.Split(env("SAW_ANDROID_CERT_SHA256", ""), ","), AndroidCerts: strings.Split(env("SAW_ANDROID_CERT_SHA256", ""), ","),
Push: pushSender(ctx, st, baseURL), Push: pushSender(ctx, st, baseURL),
Playtime: pt,
}, st, rc, poller, sacfg) }, st, rc, poller, sacfg)
if err != nil { if err != nil {
return err 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}} 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. // openDB connects with SAW_DB_DSN, or with the DatabaseConfig in CS2-SimpleAdmin.json.
func openDB(sacfg *saconfig.File) (*sql.DB, error) { func openDB(sacfg *saconfig.File) (*sql.DB, error) {
var cfg *mysql.Config var cfg *mysql.Config