Playtime from PlaytimeHistory's database (SAW_PT_DSN)

A public Playtime tab and player-drawer playtime, and staff-only figures
(who's on, staff activity, disconnect reasons, countries, names, sessions)
for @css/generic on the staff panel and on the public site when signed in.
Hidden unless SAW_PT_DSN is set and its tables are there.
This commit is contained in:
Astra 2026-10-04 21:05:57 +01:00
parent daa7ec2dbc
commit 6d0fc6e463
18 changed files with 2238 additions and 12 deletions

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

@ -0,0 +1,354 @@
package web
import (
"context"
"log/slog"
"net/http"
"slices"
"strings"
"sync"
"time"
"git.zio.sh/cs2/simpleadmin-web/internal/live"
"git.zio.sh/cs2/simpleadmin-web/internal/playtime"
"git.zio.sh/cs2/simpleadmin-web/internal/steam"
)
// PlaytimeHistory (plugins/PlaytimeHistory in the cs2 repo) records who played when, in its own
// database (SAW_PT_DSN). Without that database, or its tables, nothing about playtime shows
// anywhere. With tables but nothing recorded yet, only staff see that it's there.
//
// Anyone sees playtime, stats and maps. Staff with playtimeStaffPerm also see who's on with their
// location, staff activity, disconnect reasons, countries, and a player's names, sessions and ping:
// on the staff panel's Playtime page, and on the public site when they're signed in.
const playtimePlugin = "PlaytimeHistory"
// playtimeStaffPerm matches PlaytimeHistory's own default for looking up other players.
const playtimeStaffPerm = "@css/generic"
// Playtime states for /api/me.
const (
ptOff = "" // not configured, the tables are missing, or the database is down
ptEmpty = "empty" // the tables are there, but nothing's recorded yet
ptOn = "on"
)
type ptCache struct {
pt *playtime.Store
ttl time.Duration
mu sync.Mutex
state string
fetched time.Time
logged string // the last state or error logged, so a down database logs once
overviews map[string]ptOverview
}
type ptOverview struct {
at time.Time
view playtime.Overview
}
func (c *ptCache) get(ctx context.Context) string {
if c == nil {
return ptOff
}
c.mu.Lock()
defer c.mu.Unlock()
if !c.fetched.IsZero() && time.Since(c.fetched) < c.ttl {
return c.state
}
tables, data, err := c.pt.State(ctx)
state, note := ptOff, ""
switch {
case err != nil:
note = "PlaytimeHistory's database isn't answering; playtime is hidden: " + err.Error()
case !tables:
note = "PlaytimeHistory's tables aren't in its database; playtime is hidden"
case !data:
state, note = ptEmpty, "PlaytimeHistory hasn't recorded anything yet"
default:
state, note = ptOn, "PlaytimeHistory found"
}
if note != c.logged {
slog.Info("playtime", "state", state, "note", note)
c.logged = note
}
c.state, c.fetched = state, time.Now()
return state
}
// overview caches the public page per time zone for a minute: it's the heaviest query here.
func (c *ptCache) overview(ctx context.Context, loc *time.Location) (playtime.Overview, error) {
c.mu.Lock()
if o, ok := c.overviews[loc.String()]; ok && time.Since(o.at) < c.ttl {
c.mu.Unlock()
return o.view, nil
}
c.mu.Unlock()
v, err := c.pt.Overview(ctx, time.Now(), loc)
if err != nil {
return v, err
}
c.mu.Lock()
if c.overviews == nil || len(c.overviews) > 64 {
c.overviews = map[string]ptOverview{}
}
c.overviews[loc.String()] = ptOverview{at: time.Now(), view: v}
c.mu.Unlock()
return v, nil
}
func (s *Server) ptState(ctx context.Context) string { return s.ptc.get(ctx) }
// ptStaff is whether this viewer may see the staff-only playtime figures.
func (s *Server) ptStaff(ctx context.Context, id *Identity) bool {
return id != nil && id.Has(playtimeStaffPerm) && s.ptState(ctx) != ptOff
}
// viewerZone is the browser's time zone (?tz=Europe/London), for days and hours of the day.
func viewerZone(r *http.Request) *time.Location {
name := r.URL.Query().Get("tz")
if name == "" || len(name) > 64 || strings.Contains(name, "..") {
return time.UTC
}
loc, err := time.LoadLocation(name)
if err != nil {
return time.UTC
}
return loc
}
// periodSince reads ?period=7d|30d|all; all is nil.
func periodSince(r *http.Request, allowAll bool) (string, *time.Time) {
now := time.Now()
switch p := r.URL.Query().Get("period"); {
case p == "30d":
t := now.Add(-30 * 24 * time.Hour)
return p, &t
case p == "all" && allowAll:
return p, nil
default:
t := now.Add(-7 * 24 * time.Hour)
return "7d", &t
}
}
type leaderView struct {
playtime.Leader
Rank *rankView `json:"rank,omitempty"`
}
// publicPlaytime is the public Playtime page: ?period= for the leaderboard, ?tz= for the charts.
func (s *Server) publicPlaytime(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if s.ptState(ctx) != ptOn {
fail(w, http.StatusNotFound, "Playtime isn't being recorded.")
return
}
o, err := s.ptc.overview(ctx, viewerZone(r))
if err != nil {
internal(w, r, err)
return
}
period, since := periodSince(r, true)
leaders, err := s.pt.Leaders(ctx, since, 10)
if err != nil {
internal(w, r, err)
return
}
x, err := s.staffIdx(ctx)
if err != nil {
internal(w, r, err)
return
}
lv := make([]leaderView, 0, len(leaders))
for _, l := range leaders {
lv = append(lv, leaderView{Leader: l, Rank: s.rankOf(x, l.SteamID)})
}
writeJSON(w, http.StatusOK, map[string]any{"overview": o, "period": period, "leaders": lv})
}
type onlineView struct {
SteamID string `json:"steamid"`
Name string `json:"name"`
Rank *rankView `json:"rank,omitempty"`
Team int `json:"team"`
Ping int `json:"ping"`
playtime.Online
}
type activityView struct {
SteamID string `json:"steamid"`
Name string `json:"name"`
Rank *rankView `json:"rank,omitempty"`
Online bool `json:"online"`
playtime.Activity
}
// adminPlaytime is the staff-only half of the Playtime page (?period=7d|30d for staff activity).
// The public half comes from publicPlaytime.
func (s *Server) adminPlaytime(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
ctx := r.Context()
if !id.Has(playtimeStaffPerm) {
fail(w, http.StatusForbidden, "You need "+playtimeStaffPerm+" to see staff playtime.")
return
}
state := s.ptState(ctx)
if state == ptOff {
fail(w, http.StatusNotFound, "The panel can't reach PlaytimeHistory's database, or its tables aren't there.")
return
}
now := time.Now()
month := now.Add(-30 * 24 * time.Hour)
st := s.poll.Current()
// Who's on comes from the live status, which leaves out admins hidden with css_hide.
humans := st.Humans()
ids := make([]uint64, 0, len(humans))
for _, p := range humans {
if n, err := playtime.ParseID(p.SteamID); err == nil {
ids = append(ids, n)
}
}
open, err := s.pt.Online(ctx, ids)
if err != nil {
internal(w, r, err)
return
}
online := make([]onlineView, 0, len(humans))
for _, p := range humans {
online = append(online, onlineView{SteamID: p.SteamID, Name: p.Name, Rank: s.rankOf(x, p.SteamID),
Team: p.Team, Ping: p.Ping, Online: open[p.SteamID]})
}
// Longest on first; anyone without a recorded session last.
slices.SortStableFunc(online, func(a, b onlineView) int {
switch {
case a.Since == nil || b.Since == nil:
return boolInt(a.Since == nil) - boolInt(b.Since == nil)
default:
return a.Since.Compare(*b.Since)
}
})
period, since := periodSince(r, false)
var staffIDs []uint64
for _, a := range x.admins {
if n, err := playtime.ParseID(a.SteamID); err == nil && s.isStaff(x, a.SteamID) {
staffIDs = append(staffIDs, n)
}
}
act, err := s.pt.Activity(ctx, staffIDs, *since)
if err != nil {
internal(w, r, err)
return
}
staffList := []activityView{}
for _, a := range x.admins {
if !s.isStaff(x, a.SteamID) {
continue
}
_, on := st.Find(a.SteamID)
staffList = append(staffList, activityView{SteamID: a.SteamID, Name: a.Name, Rank: s.rankOf(x, a.SteamID),
Online: on, Activity: act[a.SteamID]})
}
slices.SortStableFunc(staffList, func(a, b activityView) int {
if a.Hours != b.Hours {
if a.Hours > b.Hours {
return -1
}
return 1
}
return x.groupIndex(x.primaryGroup(x.bySteam[a.SteamID])) - x.groupIndex(x.primaryGroup(x.bySteam[b.SteamID]))
})
reasons, err := s.pt.Reasons(ctx, month)
if err != nil {
internal(w, r, err)
return
}
countries, err := s.pt.Countries(ctx)
if err != nil {
internal(w, r, err)
return
}
ends, err := s.pt.EndKinds(ctx, month)
if err != nil {
internal(w, r, err)
return
}
resp := map[string]any{
"state": state, "period": period, "online": online, "staff": staffList,
"reasons": reasons, "countries": countries,
"endKinds": map[string]int{
"open": ends[playtime.EndOpen], "disconnect": ends[playtime.EndDisconnect], "mapChange": ends[playtime.EndMapChange],
"unload": ends[playtime.EndUnload], "stale": ends[playtime.EndStale],
},
}
// The recorder's own status can carry database errors, so it's for Root, as css_pt_status is.
if id.Has("@css/root") {
resp["recorder"] = s.ptRecorder()
}
writeJSON(w, http.StatusOK, resp)
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
// ptRecorder is whether PlaytimeHistory is loaded and, if it is, what css_pt_status says.
func (s *Server) ptRecorder() map[string]any {
state, version := s.poll.Plugin(playtimePlugin)
out := map[string]any{"plugin": state, "version": version, "lines": []string{}}
if state != live.PluginLoaded {
return out
}
res, err := s.rc.Exec("css_pt_status")
if err != nil {
return out
}
var lines []string
for _, l := range strings.Split(res, "\n") {
if l = strings.TrimSpace(l); strings.HasPrefix(l, "[Playtime] ") {
lines = append(lines, strings.TrimPrefix(l, "[Playtime] "))
}
}
if lines != nil {
out["lines"] = lines
}
return out
}
// playerPlaytime is the Playtime part of a player's drawer, or nil when playtime isn't shown.
// Errors are logged and leave it out: playtime is extra, never worth failing the drawer.
func (s *Server) playerPlaytime(r *http.Request, steamid string, staff bool) map[string]any {
ctx := r.Context()
state := s.ptState(ctx)
if state == ptOff || (state == ptEmpty && !staff) || !steam.ValidID(steamid) {
return nil
}
n, err := playtime.ParseID(steamid)
if err != nil {
return nil
}
now := time.Now()
p, err := s.pt.Player(ctx, n, now, viewerZone(r))
if err != nil {
slog.Warn("playtime for a player", "steamid", steamid, "err", err)
return nil
}
out := map[string]any{"player": p}
if staff {
extra, err := s.pt.PlayerStaff(ctx, n, now)
if err != nil {
slog.Warn("staff playtime for a player", "steamid", steamid, "err", err)
return out
}
out["staff"] = extra
}
return out
}