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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue