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.
354 lines
9.9 KiB
Go
354 lines
9.9 KiB
Go
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
|
|
}
|