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

View file

@ -245,6 +245,9 @@ func (s *Server) adminPlayer(w http.ResponseWriter, r *http.Request, id *Identit
} else if v != nil {
resp["verify"] = v
}
if pt := s.playerPlaytime(r, steamid, s.ptStaff(ctx, id)); pt != nil {
resp["playtime"] = pt
}
writeJSON(w, http.StatusOK, resp)
}

View file

@ -56,6 +56,7 @@
<div id="toast-root" aria-live="polite"></div>
<script src="/static/common.js"></script>
<script src="/static/playtime.js"></script>
<script src="/static/admin.js"></script>
</body>
</html>

View file

@ -6,6 +6,8 @@ const PAGES = [
{ id: "players", label: "Players" },
{ id: "bans", label: "Bans" },
{ 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" },
// Only when GroupVerify's tables are in the database.
{ id: "verify", label: "Verification", show: () => !!state.me?.verify },
@ -798,6 +800,7 @@ async function openPlayer(sid) {
</dl>
</section>
${verifySection(d.verify, p.steamid, name)}
${ptDrawer(d.playtime, d.online)}
${manage ? `<section>
<h3>Rank</h3>
<div style="display:flex;gap:8px">
@ -941,7 +944,9 @@ async function act(path, method, body) {
/* ---------- 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() {
const c = state.counts;

View 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; });

View file

@ -34,6 +34,7 @@
<div id="toast-root" aria-live="polite"></div>
<script src="/static/common.js"></script>
<script src="/static/playtime.js"></script>
<script src="/static/public.js"></script>
</body>
</html>

View file

@ -1,14 +1,18 @@
// Public, read-only site: the live server, bans, gags/mutes, and staff with their rank changes. The
// API leaves out who issued penalties and IPs; nothing here can change anything.
// Public, read-only site: the live server, bans, gags/mutes, staff with their rank changes, and
// 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 = [
{ id: "server", label: "Server" },
{ id: "bans", label: "Bans" },
{ id: "comms", label: "Gags & mutes" },
{ 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) {
const i = title.indexOf(".");
@ -242,6 +246,7 @@ async function openPlayer(sid) {
${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>
</section>` : ""}
${ptDrawer(p.playtime, p.online)}
<section>
<h3>History</h3>
${history.length ? `<ul class="history">${history.map((h) => `<li>
@ -256,11 +261,20 @@ async function openPlayer(sid) {
/* ---------- 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() {
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
// player, showing every status so lifted and expired ones are there too.
const q = new URLSearchParams(query || "").get("q");
@ -269,7 +283,7 @@ function route() {
state.num = 1;
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]();
}
@ -283,6 +297,7 @@ document.addEventListener("input", (e) => {
document.addEventListener("click", (e) => {
const t = e.target;
const seg = t.closest("[data-seg] button");
if (seg && seg.parentElement.dataset.seg.startsWith("pt")) return; // playtime.js
if (seg) {
state[seg.parentElement.dataset.seg] = seg.dataset.value;
state.num = 1;
@ -303,10 +318,15 @@ document.addEventListener("keydown", (e) => {
});
api("/api/me").then((me) => {
state.me = me;
$("#site-name").innerHTML = siteName(me.site);
document.title = `${me.site} bans and staff`;
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.
setInterval(() => { if (state.page === "server" && !document.hidden && !$("#drawer-root").innerHTML) server(); }, 15000);

View file

@ -1082,3 +1082,120 @@ a.btn { text-decoration: none; }
background: var(--accent-soft);
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; }
}

View file

@ -182,8 +182,13 @@ type meView struct {
Perms []string `json:"perms,omitempty"`
Immunity int `json:"immunity,omitempty"`
// Verify is true when GroupVerify's tables are in the database, for the Verification page.
Verify bool `json:"verify,omitempty"`
Site string `json:"site"`
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"`
}
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
@ -203,6 +208,8 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
if info, err := s.verifyInfo(r.Context()); err == nil {
v.Verify = info.Tables
}
v.PlaytimeStaff = s.ptStaff(r.Context(), id)
}
v.Playtime = s.ptState(r.Context())
writeJSON(w, http.StatusOK, v)
}

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
}

View file

@ -292,8 +292,18 @@ func (s *Server) publicPlayer(w http.ResponseWriter, r *http.Request) {
_, online := s.poll.Current().Find(steamid)
// 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]
writeJSON(w, http.StatusOK, map[string]any{
resp := map[string]any{
"steamid": steamid, "name": d.Name, "online": online, "rank": s.rankOf(x, steamid),
"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)
}

View file

@ -14,6 +14,7 @@ import (
"time"
"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/rcon"
"git.zio.sh/cs2/simpleadmin-web/internal/saconfig"
@ -38,6 +39,8 @@ type Config struct {
AndroidCerts []string
// Push sends staff notifications; nil turns them off.
Push *push.Sender
// Playtime reads PlaytimeHistory's database; nil turns playtime off.
Playtime *playtime.Store
}
type Server struct {
@ -49,6 +52,8 @@ type Server struct {
sacfg *saconfig.File // nil when no config path is set
staff *staffCache
verify *verifyCache
pt *playtime.Store // nil without SAW_PT_DSN
ptc *ptCache // nil without SAW_PT_DSN
avatars *avatarCache
http *http.Client
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 == "" {
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{
cfg: cfg,
site: site,
@ -72,6 +81,8 @@ func New(cfg Config, st *store.Store, rc *rcon.Client, poll *live.Poller, sacfg
sacfg: sacfg,
staff: &staffCache{st: st, ttl: 15 * time.Second},
verify: &verifyCache{st: st, ttl: 30 * time.Second},
pt: cfg.Playtime,
ptc: ptc,
avatars: newAvatarCache(&http.Client{Timeout: 10 * time.Second}),
http: &http.Client{Timeout: 10 * time.Second},
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/ranks", s.publicRankChanges)
mux.HandleFunc("GET /api/public/players/{steamid}", s.publicPlayer)
mux.HandleFunc("GET /api/public/playtime", s.publicPlaytime)
mux.HandleFunc("GET /avatar/{steamid}", s.avatar)
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/settings", s.adminSettings)
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/lift", s.liftPenalty)