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:
parent
daa7ec2dbc
commit
6d0fc6e463
18 changed files with 2238 additions and 12 deletions
53
README.md
53
README.md
|
|
@ -13,6 +13,10 @@ A SourceBans-style web panel for [CS2-SimpleAdmin](https://github.com/daffyyyy/C
|
||||||
and the game server going down, on phones and computers, with quiet hours (see below).
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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=
|
||||||
|
|
|
||||||
771
internal/playtime/playtime.go
Normal file
771
internal/playtime/playtime.go
Normal 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()
|
||||||
|
}
|
||||||
262
internal/playtime/playtime_db_test.go
Normal file
262
internal/playtime/playtime_db_test.go
Normal 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")
|
||||||
|
}
|
||||||
44
internal/playtime/playtime_test.go
Normal file
44
internal/playtime/playtime_test.go
Normal 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
141
internal/playtime/testdata/schema.sql
vendored
Normal 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;
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
390
internal/web/assets/playtime.js
Normal file
390
internal/web/assets/playtime.js
Normal 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; });
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,11 @@ type meView struct {
|
||||||
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"`
|
||||||
|
// 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"`
|
Site string `json:"site"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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
354
internal/web/playtime.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
28
main.go
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue