K/D, headshot %, kills and deaths (drawer, Top players, staff session lists) now come from pt_events, so kills of bots and deaths to bots don't count, as in Records; damage per round and MVPs are gone, since only the game's counters have them and those include bots. Average ping and the record count footnote are no longer shown. The server header's grid now runs behind the whole page.
370 lines
10 KiB
Go
370 lines
10 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) {
|
|
return periodParam(r.URL.Query().Get("period"), allowAll)
|
|
}
|
|
|
|
// periodParam turns 7d, 30d or all into when it starts; all is nil. Anything else is 7d.
|
|
func periodParam(p string, allowAll bool) (string, *time.Time) {
|
|
now := time.Now()
|
|
switch {
|
|
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)})
|
|
}
|
|
resp := map[string]any{"overview": o, "period": period, "leaders": lv}
|
|
// Records (?records=7d|30d|all) need pt_events, from PlaytimeHistory 0.2.0.
|
|
if s.pt.HasEvents() {
|
|
rp, rsince := periodParam(r.URL.Query().Get("records"), true)
|
|
rec, err := s.pt.Records(ctx, rsince, 5)
|
|
if err != nil {
|
|
internal(w, r, err)
|
|
return
|
|
}
|
|
resp["records"], resp["recordsPeriod"] = rec, rp
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
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)
|
|
if err != nil {
|
|
slog.Warn("staff playtime for a player", "steamid", steamid, "err", err)
|
|
return out
|
|
}
|
|
out["staff"] = extra
|
|
}
|
|
return out
|
|
}
|