mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[www] stats
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
||||
where,
|
||||
} from './api'
|
||||
import { ModerationPage } from './Moderation'
|
||||
import { StatsPage } from './Stats'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Hosts } from './api'
|
||||
@@ -836,6 +837,12 @@ export function App() {
|
||||
// on any of them falls through to the SPA shell; the page then gates itself on
|
||||
// the token's role, and every endpoint behind it re-checks.
|
||||
<ModerationPage account={account} path={path} search={search} navigate={navigate} />
|
||||
) : path === '/stats' ? (
|
||||
// Unlinked on purpose — nothing in the nav or footer points here; it's for whoever
|
||||
// is handed the URL. Public all the same, and a client-side route like the rest:
|
||||
// its data comes from `/api/stats/online`, which the existing `/api/*` allowlist
|
||||
// entry already sends to the Worker.
|
||||
<StatsPage search={search} navigate={navigate} />
|
||||
) : roomId !== null ? (
|
||||
<RoomPage account={account} roomId={roomId} navigate={navigate} />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { call } from './api'
|
||||
|
||||
import type { KeyboardEvent, PointerEvent } from 'react'
|
||||
|
||||
/**
|
||||
* The public stats page, at `/stats`: how many players are online, and how that has
|
||||
* moved. One series — the `online` samples the `match` presence cron writes to `stat` —
|
||||
* read from `www`'s own `/api/stats/online` (see www.app.ts), which folds them into
|
||||
* buckets so the quarter view doesn't ship 26,000 points.
|
||||
*
|
||||
* Its own file for the reason Moderation.tsx is: a whole surface rather than one more
|
||||
* form. The chart is hand-drawn SVG — one line doesn't justify a charting dependency in a
|
||||
* bundle whose homepage is a poster.
|
||||
*/
|
||||
|
||||
type Range = '24h' | '7d' | '30d' | '90d'
|
||||
|
||||
// Shortest first: "what's it like right now" is the question most visitors arrive with.
|
||||
const RANGES: Array<{ id: Range; label: string; title: string }> = [
|
||||
{ id: '24h', label: '24 hours', title: 'the last 24 hours' },
|
||||
{ id: '7d', label: '7 days', title: 'the last 7 days' },
|
||||
{ id: '30d', label: '30 days', title: 'the last 30 days' },
|
||||
{ id: '90d', label: '90 days', title: 'the last 90 days' },
|
||||
]
|
||||
|
||||
interface Point {
|
||||
/** Start of the bucket, epoch seconds. */
|
||||
t: number
|
||||
/** The bucket's peak. */
|
||||
players: number
|
||||
}
|
||||
|
||||
interface OnlineStats {
|
||||
range: Range
|
||||
bucketSeconds: number
|
||||
from: number
|
||||
to: number
|
||||
online: number
|
||||
peak: number
|
||||
average: number
|
||||
points: Point[]
|
||||
}
|
||||
|
||||
/** How often the page re-asks. The cron samples every five minutes; the live count moves faster. */
|
||||
const REFRESH_MS = 60_000
|
||||
|
||||
const DAY = 86_400
|
||||
|
||||
function rangeFromSearch(search: string): Range {
|
||||
const asked = new URLSearchParams(search).get('range')
|
||||
return RANGES.find((r) => r.id === asked)?.id ?? '24h'
|
||||
}
|
||||
|
||||
export function StatsPage({
|
||||
search,
|
||||
navigate,
|
||||
}: {
|
||||
search: string
|
||||
navigate: (to: string) => void
|
||||
}) {
|
||||
// The range lives in the URL so a view is linkable ("look at last month") and the back
|
||||
// button walks back through the ranges, the way the moderation panel's filters do.
|
||||
const range = rangeFromSearch(search)
|
||||
const [stats, setStats] = useState<OnlineStats | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const load = () =>
|
||||
call<OnlineStats>(`/api/stats/online?range=${range}`)
|
||||
.then((next) => {
|
||||
if (cancelled) return
|
||||
setStats(next)
|
||||
setError('')
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (!cancelled) setError(e.message)
|
||||
})
|
||||
void load()
|
||||
// Only while someone is looking: a backgrounded tab polling a public endpoint all
|
||||
// night is traffic nobody reads.
|
||||
const timer = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') void load()
|
||||
}, REFRESH_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [range])
|
||||
|
||||
// The previous range's numbers stay on screen (dimmed) until the new ones land, so
|
||||
// flipping ranges doesn't collapse the page to a spinner and back.
|
||||
const stale = stats !== null && stats.range !== range
|
||||
// Named from the data on screen, not the range just clicked: while the new one loads,
|
||||
// the old chart must not be captioned as the new window.
|
||||
const shown = RANGES.find((r) => r.id === stats?.range)?.title ?? ''
|
||||
|
||||
return (
|
||||
<main className="shell home stats">
|
||||
<header className="stats-head">
|
||||
<h1>Players online</h1>
|
||||
<p className="muted">How many people are in the game, now and lately.</p>
|
||||
</header>
|
||||
|
||||
<div className="stats-ranges" role="group" aria-label="Time range">
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
className={r.id === range ? 'active' : ''}
|
||||
aria-pressed={r.id === range}
|
||||
onClick={() => navigate(r.id === '24h' ? '/stats' : `/stats?range=${r.id}`)}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && stats === null ? (
|
||||
<p className="error">Couldn’t load the stats: {error}</p>
|
||||
) : stats === null ? (
|
||||
<p className="muted">Loading…</p>
|
||||
) : (
|
||||
<div className={stale ? 'stats-body stale' : 'stats-body'}>
|
||||
<dl className="stat-tiles">
|
||||
<div className="stat-tile">
|
||||
<dt>
|
||||
<span className="stat-live-dot" aria-hidden="true" />
|
||||
Online now
|
||||
</dt>
|
||||
<dd className="hero">{formatCount(stats.online)}</dd>
|
||||
</div>
|
||||
<div className="stat-tile">
|
||||
<dt>Peak</dt>
|
||||
<dd>{formatCount(stats.peak)}</dd>
|
||||
</div>
|
||||
<div className="stat-tile">
|
||||
<dt>Average</dt>
|
||||
<dd>{formatAverage(stats.average)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section className="card stats-card">
|
||||
<h2>{chartTitle(stats.bucketSeconds)}</h2>
|
||||
<p className="muted">Over {shown}, in your local time.</p>
|
||||
{stats.points.length === 0 ? (
|
||||
<p className="stats-empty muted">Nothing has been recorded in this window yet.</p>
|
||||
) : (
|
||||
<>
|
||||
<OnlineChart stats={stats} />
|
||||
<details className="stats-table">
|
||||
<summary>View as a table</summary>
|
||||
<div className="stats-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">When</th>
|
||||
<th scope="col">Players</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* Newest first: the row someone opens a table for is the latest. */}
|
||||
{[...stats.points].reverse().map((p) => (
|
||||
<tr key={p.t}>
|
||||
<td>{bucketLabel(p.t, stats.bucketSeconds)}</td>
|
||||
<td>{formatCount(p.players)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
{error && <p className="error">Couldn’t refresh: {error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Formatting ------------------------------------------------------------
|
||||
|
||||
const countFormat = new Intl.NumberFormat()
|
||||
const formatCount = (n: number) => countFormat.format(n)
|
||||
|
||||
/** One decimal while the numbers are small enough for it to mean something. */
|
||||
const formatAverage = (n: number) =>
|
||||
new Intl.NumberFormat(undefined, { maximumFractionDigits: n < 100 ? 1 : 0 }).format(n)
|
||||
|
||||
/** Says what a point IS: a raw sample on the day view, a bucket's peak on the rest. */
|
||||
function chartTitle(bucketSeconds: number): string {
|
||||
if (bucketSeconds <= 300) return 'Players online, every 5 minutes'
|
||||
if (bucketSeconds >= DAY) return 'Peak players online, per day'
|
||||
const hours = bucketSeconds / 3600
|
||||
return hours === 1 ? 'Peak players online, per hour' : `Peak players online, per ${hours} hours`
|
||||
}
|
||||
|
||||
const timeFormat = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
|
||||
const dayTimeFormat = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
// A day bucket is a UTC day (the server folds on epoch seconds), so it is NAMED in UTC —
|
||||
// formatting its start in local time would call it "Sep 13, 8 PM" for half the world.
|
||||
const utcDayFormat = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
|
||||
function bucketLabel(t: number, bucketSeconds: number): string {
|
||||
if (bucketSeconds >= DAY) return `${utcDayFormat.format(t * 1000)} (UTC)`
|
||||
const start = dayTimeFormat.format(t * 1000)
|
||||
if (bucketSeconds <= 300) return start
|
||||
return `${start} – ${timeFormat.format((t + bucketSeconds) * 1000)}`
|
||||
}
|
||||
|
||||
// ---- Axes ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Y ticks on whole, round numbers. Whole matters more than round here: this is a count of
|
||||
* people, and a young server's axis topping out at "2.5" reads as a bug.
|
||||
*/
|
||||
function yTicks(max: number): number[] {
|
||||
const rough = Math.max(max, 1) / 4
|
||||
const magnitude = 10 ** Math.floor(Math.log10(rough))
|
||||
const step = Math.max(1, [1, 2, 5, 10].map((m) => m * magnitude).find((s) => s >= rough) ?? 1)
|
||||
const top = Math.max(step, Math.ceil(max / step) * step)
|
||||
const ticks: number[] = []
|
||||
for (let v = 0; v <= top; v += step) ticks.push(v)
|
||||
return ticks
|
||||
}
|
||||
|
||||
const HOUR_STEPS = [1, 2, 3, 4, 6, 12]
|
||||
const DAY_STEPS = [1, 2, 3, 7, 14, 28]
|
||||
const hourTick = new Intl.DateTimeFormat(undefined, { hour: 'numeric' })
|
||||
const dayTick = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' })
|
||||
|
||||
/**
|
||||
* X ticks on LOCAL clock boundaries (midnights, or hours divisible by the step), as many
|
||||
* as fit: the step is the smallest whose labels get `minGap` pixels each, so the same
|
||||
* code serves a phone and a desktop without labels colliding.
|
||||
*/
|
||||
function xTicks(from: number, to: number, width: number, minGap: number) {
|
||||
const fits = (stepSeconds: number) => ((to - from) / stepSeconds) * minGap <= width
|
||||
const hours = HOUR_STEPS.find((h) => fits(h * 3600))
|
||||
const ticks: Array<{ t: number; label: string }> = []
|
||||
if (to - from <= 2 * DAY && hours !== undefined) {
|
||||
const cursor = new Date(from * 1000)
|
||||
cursor.setMinutes(0, 0, 0)
|
||||
for (; cursor.getTime() / 1000 <= to; cursor.setHours(cursor.getHours() + 1)) {
|
||||
const t = cursor.getTime() / 1000
|
||||
if (t >= from && cursor.getHours() % hours === 0) {
|
||||
ticks.push({ t, label: hourTick.format(cursor) })
|
||||
}
|
||||
}
|
||||
return ticks
|
||||
}
|
||||
const days = DAY_STEPS.find((d) => fits(d * DAY)) ?? DAY_STEPS[DAY_STEPS.length - 1]!
|
||||
const midnights: Date[] = []
|
||||
const cursor = new Date(from * 1000)
|
||||
cursor.setHours(0, 0, 0, 0)
|
||||
// setDate rather than adding 86,400s: a DST day isn't 24 hours long.
|
||||
for (; cursor.getTime() / 1000 <= to; cursor.setDate(cursor.getDate() + 1)) {
|
||||
if (cursor.getTime() / 1000 >= from) midnights.push(new Date(cursor))
|
||||
}
|
||||
// Counted back from the newest so the most recent midnight is always labelled.
|
||||
midnights.forEach((d, i) => {
|
||||
if ((midnights.length - 1 - i) % days === 0) {
|
||||
ticks.push({ t: d.getTime() / 1000, label: dayTick.format(d) })
|
||||
}
|
||||
})
|
||||
return ticks
|
||||
}
|
||||
|
||||
// ---- Chart -----------------------------------------------------------------
|
||||
|
||||
const HEIGHT = 300
|
||||
const MARGIN = { top: 16, right: 16, bottom: 28, left: 40 }
|
||||
|
||||
/**
|
||||
* The line. X spans the WINDOW (`from`–`to`), not the data, so a server with three days
|
||||
* of history shows three days at the right of the 90-day view instead of stretching them
|
||||
* across it. The line BREAKS where buckets are missing — the cron didn't run, which is
|
||||
* an outage, and a straight line drawn across it would invent players for the gap.
|
||||
*/
|
||||
function OnlineChart({ stats }: { stats: OnlineStats }) {
|
||||
const { points, from, to, bucketSeconds } = stats
|
||||
const frame = useRef<HTMLDivElement>(null)
|
||||
// Measured rather than scaled through a viewBox: scaling would shrink the axis type on
|
||||
// a phone along with the plot.
|
||||
const [width, setWidth] = useState(0)
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = frame.current
|
||||
if (!el) return
|
||||
setWidth(el.clientWidth)
|
||||
const observer = new ResizeObserver(() => setWidth(el.clientWidth))
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// A hovered index means nothing against a different series.
|
||||
useEffect(() => setHover(null), [stats])
|
||||
|
||||
const plot = useMemo(() => {
|
||||
const innerW = Math.max(width - MARGIN.left - MARGIN.right, 1)
|
||||
const innerH = HEIGHT - MARGIN.top - MARGIN.bottom
|
||||
const ticks = yTicks(points.reduce((max, p) => Math.max(max, p.players), 0))
|
||||
const top = ticks[ticks.length - 1] ?? 1
|
||||
const x = (t: number) => MARGIN.left + ((t - from) / (to - from)) * innerW
|
||||
const y = (v: number) => MARGIN.top + innerH - (v / top) * innerH
|
||||
|
||||
// Runs of consecutive buckets. 2.5× tolerates one late sample without breaking the
|
||||
// line, and still breaks it for a real hole.
|
||||
const runs: Point[][] = []
|
||||
for (const p of points) {
|
||||
const run = runs[runs.length - 1]
|
||||
const last = run?.[run.length - 1]
|
||||
if (run && last && p.t - last.t <= bucketSeconds * 2.5) run.push(p)
|
||||
else runs.push([p])
|
||||
}
|
||||
const baseline = y(0)
|
||||
const paths = runs.map((run) => {
|
||||
const line = run.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.t)},${y(p.players)}`).join('')
|
||||
const first = run[0]!
|
||||
const last = run[run.length - 1]!
|
||||
return {
|
||||
line,
|
||||
area: `${line}L${x(last.t)},${baseline}L${x(first.t)},${baseline}Z`,
|
||||
// A one-bucket run has no length to stroke; it gets a dot instead.
|
||||
lone: run.length === 1 ? first : null,
|
||||
}
|
||||
})
|
||||
return { x, y, ticks, paths, baseline, innerW }
|
||||
}, [points, from, to, bucketSeconds, width])
|
||||
|
||||
const nearest = (clientX: number): number | null => {
|
||||
const el = frame.current
|
||||
if (!el || points.length === 0) return null
|
||||
const px = clientX - el.getBoundingClientRect().left
|
||||
let best = 0
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
if (Math.abs(plot.x(points[i]!.t) - px) < Math.abs(plot.x(points[best]!.t) - px)) best = i
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => setHover(nearest(e.clientX))
|
||||
|
||||
// The same readout from the keyboard: arrows walk the points, Home/End jump, Escape
|
||||
// puts it away.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const last = points.length - 1
|
||||
const moves: Record<string, number | null> = {
|
||||
ArrowLeft: Math.max((hover ?? points.length) - 1, 0),
|
||||
ArrowRight: Math.min((hover ?? -1) + 1, last),
|
||||
Home: 0,
|
||||
End: last,
|
||||
Escape: null,
|
||||
}
|
||||
if (!(e.key in moves)) return
|
||||
e.preventDefault()
|
||||
setHover(moves[e.key] ?? null)
|
||||
}
|
||||
|
||||
const hovered = hover === null ? undefined : points[hover]
|
||||
const latest = points[points.length - 1]!
|
||||
const tipX = hovered ? plot.x(hovered.t) : 0
|
||||
// Past the midpoint the readout sits to the LEFT of the crosshair, so it never runs off
|
||||
// the card.
|
||||
const tipLeft = tipX > width / 2
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={frame}
|
||||
className="stats-chart"
|
||||
tabIndex={0}
|
||||
role="group"
|
||||
aria-label={`Line chart of players online. Latest: ${formatCount(latest.players)}. Use the arrow keys to read each point; the same numbers are in the table below.`}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => setHover(null)}
|
||||
>
|
||||
{width > 0 && (
|
||||
<svg
|
||||
width={width}
|
||||
height={HEIGHT}
|
||||
aria-hidden="true"
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerDown={onPointerMove}
|
||||
onPointerLeave={() => setHover(null)}
|
||||
>
|
||||
{plot.ticks.map((v) => (
|
||||
<g key={v}>
|
||||
<line
|
||||
className="grid"
|
||||
x1={MARGIN.left}
|
||||
x2={width - MARGIN.right}
|
||||
y1={plot.y(v)}
|
||||
y2={plot.y(v)}
|
||||
/>
|
||||
<text className="tick" x={MARGIN.left - 8} y={plot.y(v)} dy="0.32em" textAnchor="end">
|
||||
{formatCount(v)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{xTicks(from, to, plot.innerW, 72).map((tick) => (
|
||||
<text
|
||||
key={tick.t}
|
||||
className="tick"
|
||||
x={plot.x(tick.t)}
|
||||
y={HEIGHT - 8}
|
||||
// A label centred on a tick at the very edge would hang outside the plot.
|
||||
textAnchor={plot.x(tick.t) > width - MARGIN.right - 24 ? 'end' : 'middle'}
|
||||
>
|
||||
{tick.label}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{plot.paths.map((p, i) => (
|
||||
<g key={i}>
|
||||
<path className="area" d={p.area} />
|
||||
<path className="series" d={p.line} />
|
||||
{p.lone && (
|
||||
<circle
|
||||
className="series-dot"
|
||||
cx={plot.x(p.lone.t)}
|
||||
cy={plot.y(p.lone.players)}
|
||||
r="2.5"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
|
||||
{hovered && (
|
||||
<line className="crosshair" x1={tipX} x2={tipX} y1={MARGIN.top} y2={plot.baseline} />
|
||||
)}
|
||||
{/* The end-dot marks "latest"; while reading another point it moves there. */}
|
||||
<circle
|
||||
className="marker"
|
||||
cx={plot.x((hovered ?? latest).t)}
|
||||
cy={plot.y((hovered ?? latest).players)}
|
||||
r="5"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{hovered && (
|
||||
<div
|
||||
className="stats-tip"
|
||||
role="status"
|
||||
style={tipLeft ? { right: width - tipX + 12 } : { left: tipX + 12 }}
|
||||
>
|
||||
<strong>
|
||||
{formatCount(hovered.players)} {hovered.players === 1 ? 'player' : 'players'}
|
||||
</strong>
|
||||
<span>{bucketLabel(hovered.t, bucketSeconds)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1097,6 +1097,253 @@ button[type='submit']:disabled {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
/* ---- Stats ---------------------------------------------------------------- */
|
||||
|
||||
/*
|
||||
* `/stats`: the head-count and its history. The line wears `--live`, the same green as the
|
||||
* homepage's "online" dot, because it IS that signal drawn over time — and not the
|
||||
* orange, which this page keeps for things you can press. Everything that isn't data
|
||||
* (grid, ticks, the table) stays in `--line`/`--muted` so the line is the only loud thing.
|
||||
*/
|
||||
.shell.stats {
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.stats-head h1 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stats-head p {
|
||||
margin: 0 0 22px;
|
||||
}
|
||||
|
||||
/* One control, four positions — the auth card's `.tabs`, but hugging its labels instead
|
||||
of filling the row: a full-width bar over a chart reads as a heading. */
|
||||
.stats-ranges {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 18px;
|
||||
padding: 4px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stats-ranges button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
padding: 7px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-family: var(--body);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stats-ranges button:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stats-ranges button.active {
|
||||
background: var(--surface-hi);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* The last range's numbers, held while the next one loads. */
|
||||
.stats-body {
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.stats-body.stale {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.stat-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.stat-tile {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.stat-tile dt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.stat-tile dd {
|
||||
margin: 2px 0 0;
|
||||
font-weight: 600;
|
||||
font-size: 1.7rem;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* The one number the page leads with. */
|
||||
.stat-tile dd.hero {
|
||||
font-size: 3rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.stat-live-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--live);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--live) 22%, transparent);
|
||||
}
|
||||
|
||||
.stats-card h2 {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.stats-card > .muted {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.stats-empty {
|
||||
padding: 60px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-chart {
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
/* A finger dragging along the line reads it; it shouldn't also scroll the page sideways. */
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.stats-chart svg {
|
||||
display: block;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.stats-chart .grid {
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.stats-chart .tick {
|
||||
fill: var(--muted);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stats-chart .series {
|
||||
fill: none;
|
||||
stroke: var(--live);
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.stats-chart .series-dot {
|
||||
fill: var(--live);
|
||||
}
|
||||
|
||||
.stats-chart .area {
|
||||
fill: var(--live);
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.stats-chart .crosshair {
|
||||
stroke: var(--muted);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
/* Ringed in the surface colour so it stays legible where it sits on the line. */
|
||||
.stats-chart .marker {
|
||||
fill: var(--live);
|
||||
stroke: var(--surface);
|
||||
stroke-width: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stats-tip {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 12px;
|
||||
background: var(--surface-hi);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 20px rgb(0 0 0 / 0.25);
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stats-tip strong {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stats-table {
|
||||
margin-top: 14px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.stats-table summary {
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.stats-table summary:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stats-table-scroll {
|
||||
max-height: 320px;
|
||||
margin-top: 10px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stats-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.stats-table th,
|
||||
.stats-table td {
|
||||
padding: 7px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.stats-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--surface-hi);
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.stats-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stats-table th:last-child,
|
||||
.stats-table td:last-child {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---- Responsive --------------------------------------------------------- */
|
||||
|
||||
@media (max-width: 860px) {
|
||||
@@ -1132,6 +1379,29 @@ button[type='submit']:disabled {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Now leads on its own row; the two range figures share the one under it. */
|
||||
.stat-tiles {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-tile:first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.stats-ranges {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.stats-ranges button {
|
||||
flex: 1;
|
||||
padding: 7px 4px;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.vtabs {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ROOM_INSTANCE_SCHEMA_DDL } from '@repo/domain/src/room-instance-db'
|
||||
// Banning a player moves them into their own dorm, which is a room (created on demand) with
|
||||
// a subroom — so those tables have to be here, as they are on the shared database.
|
||||
import { ROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL } from '@repo/domain/src/rooms-db'
|
||||
import { recordStat, STAT_SCHEMA_DDL } from '@repo/domain/src/stats-db'
|
||||
import { generateToken } from '@repo/jwt'
|
||||
|
||||
import {
|
||||
@@ -92,6 +93,8 @@ beforeAll(async () => {
|
||||
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// `stat` is written by the `match` presence cron; www only reads it, for `/stats`.
|
||||
for (const stmt of STAT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
// Web signup is open, but only behind the Turnstile check. These pin the closed door:
|
||||
@@ -330,6 +333,50 @@ it('serves a public head-count of the players actually online', async () => {
|
||||
expect(await res.json()).toEqual({ status: 'online', players: 2 })
|
||||
})
|
||||
|
||||
// The series behind `/stats`. What's pinned: samples fold into the range's bucket as its
|
||||
// PEAK, the average is over the raw samples (not over the buckets), samples outside the
|
||||
// window and other stat types stay out, and the range is a closed list.
|
||||
it('serves the online series bucketed by range, peak per bucket', async () => {
|
||||
await env.DB.prepare('DELETE FROM stat').run()
|
||||
// Anchored to the start of the previous hour so the three samples provably share one
|
||||
// hourly bucket, whenever the test runs.
|
||||
const hour = Math.floor(Date.now() / 3_600_000) * 3_600_000 - 3_600_000
|
||||
await recordStat(env.DB, 'online', 4, new Date(hour))
|
||||
await recordStat(env.DB, 'online', 9, new Date(hour + 5 * 60_000))
|
||||
await recordStat(env.DB, 'online', 2, new Date(hour + 10 * 60_000))
|
||||
await recordStat(env.DB, 'online', 50, new Date(hour - 8 * 86_400_000)) // before the 7d window
|
||||
await recordStat(env.DB, 'rooms', 99, new Date(hour)) // another series entirely
|
||||
|
||||
let res = await SELF.fetch('https://example.com/api/stats/online?range=7d')
|
||||
expect(res.status).toBe(200)
|
||||
const week = await res.json<{
|
||||
bucketSeconds: number
|
||||
from: number
|
||||
to: number
|
||||
peak: number
|
||||
average: number
|
||||
points: Array<{ t: number; players: number }>
|
||||
}>()
|
||||
expect(week.bucketSeconds).toBe(3600)
|
||||
expect(week.to - week.from).toBe(7 * 86_400)
|
||||
expect(week.points).toEqual([{ t: hour / 1000, players: 9 }])
|
||||
expect(week.peak).toBe(9)
|
||||
expect(week.average).toBe(5)
|
||||
|
||||
// The default range is the raw five-minute series: one point per sample.
|
||||
res = await SELF.fetch('https://example.com/api/stats/online')
|
||||
const day = await res.json<{ range: string; points: Array<{ players: number }> }>()
|
||||
expect(day.range).toBe('24h')
|
||||
expect(day.points.map((p) => p.players)).toEqual([4, 9, 2])
|
||||
|
||||
// The 30-day window reaches the old sample the week didn't.
|
||||
res = await SELF.fetch('https://example.com/api/stats/online?range=30d')
|
||||
expect((await res.json<{ peak: number }>()).peak).toBe(50)
|
||||
|
||||
res = await SELF.fetch('https://example.com/api/stats/online?range=forever')
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('serves the aggregated docs page with a source per documented service', async () => {
|
||||
const res = await SELF.fetch('https://example.com/docs')
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
import { getAccount, updateAccount } from '@repo/domain/src/accounts-db'
|
||||
import { PlatformType } from '@repo/domain/src/enums'
|
||||
import { countOnlinePlayers } from '@repo/domain/src/presence-db'
|
||||
import { getStatSeries } from '@repo/domain/src/stats-db'
|
||||
import { logger, withDefaultCors, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
@@ -117,6 +118,20 @@ function authorizeUrl(request: Request, clientId: string): string {
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* The windows `/api/stats/online` serves, each with the bucket its samples are folded
|
||||
* into. The `match` cron samples every five minutes, so `24h` is the raw series and the
|
||||
* rest are sized to land between ~90 and ~290 points — what a chart the width of the page
|
||||
* can actually draw. A closed list rather than a free `since`: every request scans the
|
||||
* window it names, and this is a public route.
|
||||
*/
|
||||
const STAT_RANGES: Record<string, { seconds: number; bucketSeconds: number }> = {
|
||||
'24h': { seconds: 86_400, bucketSeconds: 300 },
|
||||
'7d': { seconds: 7 * 86_400, bucketSeconds: 3_600 },
|
||||
'30d': { seconds: 30 * 86_400, bucketSeconds: 4 * 3_600 },
|
||||
'90d': { seconds: 90 * 86_400, bucketSeconds: 86_400 },
|
||||
}
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -181,6 +196,44 @@ const app = new Hono<App>()
|
||||
})
|
||||
})
|
||||
|
||||
// The history behind that head-count: the `online` series the `match` presence cron
|
||||
// samples into `stat`, for the chart at `/stats`. Public and CORS-open on the same
|
||||
// terms as `/server-status`.
|
||||
//
|
||||
// Each point is the PEAK of its bucket, not the mean — "how many were on" is a question
|
||||
// about the busiest moment, and a mean over a four-hour bucket flattens an evening's
|
||||
// spike into nothing. `average` is over the raw samples, not over the buckets, so a
|
||||
// short bucket at the window's edge doesn't weigh the same as a full one. `online` is
|
||||
// the live count rather than the last sample, which can be five minutes old.
|
||||
.get('/api/stats/online', withDefaultCors(), async (c) => {
|
||||
const range = c.req.query('range') ?? '24h'
|
||||
const window = STAT_RANGES[range]
|
||||
if (!window) return c.json({ error: 'Unknown range.' }, 400)
|
||||
|
||||
const now = Date.now()
|
||||
const since = new Date(now - window.seconds * 1000)
|
||||
const [online, buckets] = await Promise.all([
|
||||
countOnlinePlayers(c.env.DB),
|
||||
getStatSeries(c.env.DB, 'online', since, window.bucketSeconds),
|
||||
])
|
||||
const samples = buckets.reduce((n, b) => n + b.samples, 0)
|
||||
// A new sample lands every five minutes, so a minute of browser cache costs nothing
|
||||
// anyone can see and spares the scan when someone flips between ranges.
|
||||
c.header('cache-control', 'public, max-age=60')
|
||||
return c.json({
|
||||
range,
|
||||
bucketSeconds: window.bucketSeconds,
|
||||
// The window itself, so the chart's x-axis spans what was ASKED for rather than
|
||||
// what came back — a server with three days of history shows three days of line.
|
||||
from: Math.floor(since.getTime() / 1000),
|
||||
to: Math.floor(now / 1000),
|
||||
online,
|
||||
peak: buckets.reduce((max, b) => Math.max(max, b.peak), 0),
|
||||
average: samples === 0 ? 0 : buckets.reduce((sum, b) => sum + b.sum, 0) / samples,
|
||||
points: buckets.map((b) => ({ t: b.t, players: b.peak })),
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Signup -------------------------------------------------------------
|
||||
|
||||
// Create an account from the website, behind a Turnstile bot check. The check is what
|
||||
|
||||
@@ -43,3 +43,44 @@ export async function getStats(db: D1Database, statType: string, limit = 1000):
|
||||
.all<{ stat_type: string; value: number; datetime: string }>()
|
||||
return results.map((r) => ({ statType: r.stat_type, value: r.value, datetime: r.datetime }))
|
||||
}
|
||||
|
||||
/** One bucket of a {@link getStatSeries} result. */
|
||||
export interface StatPoint {
|
||||
/** Start of the bucket, epoch seconds. */
|
||||
t: number
|
||||
/** Highest sample in the bucket. */
|
||||
peak: number
|
||||
/** Sum and count of the bucket's samples — kept apart so a caller can average ACROSS buckets. */
|
||||
sum: number
|
||||
samples: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The `statType` series since `since`, folded into `bucketSeconds`-wide buckets, oldest
|
||||
* first. The cron samples every five minutes, so a quarter of raw rows is ~26k points —
|
||||
* far more than a chart has pixels for; bucketing in SQL keeps the response small and
|
||||
* the peak exact. A bucket nothing was sampled in is simply absent, which is how a
|
||||
* reader tells an outage from a quiet hour.
|
||||
*
|
||||
* The range filter compares `datetime` as a string (it is ISO-8601 UTC, so it sorts
|
||||
* lexically) to stay on `idx_stat_type_datetime`; only the bucketing parses it.
|
||||
*/
|
||||
export async function getStatSeries(
|
||||
db: D1Database,
|
||||
statType: string,
|
||||
since: Date,
|
||||
bucketSeconds: number
|
||||
): Promise<StatPoint[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
// D1 binds a JS number as REAL, so the bucket width is cast back: without it the
|
||||
// division is floating-point and every sample lands in a bucket of its own.
|
||||
`SELECT (CAST(strftime('%s', datetime) AS INTEGER) / CAST(?3 AS INTEGER)) * CAST(?3 AS INTEGER) AS t,
|
||||
MAX(value) AS peak, SUM(value) AS sum, COUNT(*) AS samples
|
||||
FROM stat WHERE stat_type = ?1 AND datetime >= ?2
|
||||
GROUP BY t ORDER BY t ASC`
|
||||
)
|
||||
.bind(statType, since.toISOString(), bucketSeconds)
|
||||
.all<StatPoint>()
|
||||
return results
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user