Improve metrics clarity, exploration, and refresh reliability

This commit is contained in:
2026-09-27 22:12:11 -04:00
parent 981fb27c1c
commit 3922e28ca8
15 changed files with 984 additions and 135 deletions
+107
View File
@@ -0,0 +1,107 @@
Improve Metrics so an operator can quickly answer how much hosting time had players, which maps receive play, where measured capacity stays full, and whether the recorded history supports a decision.
This plan combines three UI/UX reviews (information architecture, visual design, and interaction/accessibility) and two software engineering reviews (frontend and metrics data). The reviewers exchanged proposals and challenged the tradeoffs. No product changes are included.
The review covered the current Svelte page, collector, SQLite aggregation, shared design system, and tests. Desktop (1440px) and mobile (390px) screenshots of the existing build were inspected using an isolated synthetic database. That fixture contained six server names, seven days of samples, an outage, unknown status, and missing capacity. It establishes layout behavior, not production usage patterns.
| Finding | Consequence | Planned response |
| --- | --- | --- |
| “Servers utilized” is occupied time divided by known server time. | It can be mistaken for a percentage of server count. | Name the measure “Time with players” and show its denominator. |
| Four equal cards include complementary occupied/idle percentages. At 390px, the chart heading starts around 970px down the page. | Repeated summary information delays useful analysis, especially on phones. | Two compact headline metrics and a nearby data-quality strip. |
| The chart has endpoint labels but no calibrated vertical ticks. Partial edge periods look like ordinary periods. | A short final bar can look like declining activity. | Add clear axes, explicit partial-period treatment, and inspectable intervals. |
| “Updated” is the report calculation time. | A fresh report can contain old samples. | Separate “Report refreshed” from “Last collected.” |
| Capacity can be absent even when telemetry supplies valid occupancy. | “Unknown” pressure can be mistaken for a failure or no pressure. | Keep known-capacity duration beside every pressure figure and explain unavailable capacity. |
| Tables have fixed ordering and limited comparison affordances. | Operators must scan raw values to identify useful patterns. | Sorting, restrained comparison bars, readable identities, and local search. |
| Metrics aggregate historical samples by server name without an instance identifier. | Linking a row to a current server can connect old history to a replacement. | Keep historical row identities unlinked in the first release. |
| A synthetic 118,806-row fixture took about 0.99–1.35 seconds to read for the seven-day view. | Synchronous aggregation can block other application requests. | Profile and reduce repeated aggregation work before expanding exploration. |
The proposed page composition is:
```text
Metrics Refresh
24 hours / 7 days / 30 days selected range · timezone
Time with players Player-hours
58.6% 1,350.5
555.7 of 948.8 known server-hours Players × observed time
Known status … · Unknown status … · Last collected …
Report refreshed … · Samples retained from …
Server time over time occupied / idle / unknown
[stacked server-hours chart with axes and marked partial periods]
[Period selector] [Previous] [Next] [selected interval and values]
View exact time data
Map play time & capacity
[sortable ranking; pressure and known-capacity time stay together]
Server utilization Find a server…
[sortable table; friendly names and secondary technical IDs]
How these estimates work
```
The example values above illustrate the synthetic preview, not a target or recommendation.
1. Establish a precise, efficient report contract.
Keep the existing 24-hour, seven-day, and 30-day windows, 45-second observation cap, UTC bucket boundaries, and 31-day retention. Add typed report metadata for the latest recorded collection sample, retained sample bounds, and current registered-server count. Return the actual clipped start/end and partial flag for each bucket. Distinguish report calculation time from sample time. Collection stops when no servers are registered, so old history alone does not imply a failed collector. Prefer factual timestamps; do not apply a ticking 45-second stale threshold to a page refreshed only every minute.
Keep the formulas explicit: occupied percentage = occupied server-hours / known server-hours; known server-hours = occupied + idle. Unknown samples are separate. Long collection gaps are excluded, so known / (known + unknown) must not be called collection coverage. Earliest retained sample does not establish first-ever collection or uninterrupted history.
Pressure remains map wall time: “All lobbies full for X during Y with known capacity.” It requires known occupancy and capacity for every tracked lobby in that recorded game/map group. It is not a measure of failed joins or unmet demand. Missing capacity must remain unknown; do not assume four slots or carry old capacity forward without a separate validity policy.
Profile realistic retained histories, then consolidate repeated sample/bucket aggregation while preserving interval clipping and pressure semantics. The current implementation queries each chart bucket separately in addition to summary and table queries. One profiled seven-day read spent roughly 349ms on summary, 158ms on servers, 441ms on maps, and 109ms across chart buckets: reducing bucket queries alone misses most of the cost. Use parity checks against existing calculations before replacing it; evaluate query plans before adding indexes. Avoid transferring raw samples to the browser. Do not introduce permanent rollups or a background analytics subsystem unless profiling justifies them.
Primary files: `src/lib/server/fleet/metrics.ts`, `src/routes/metrics/+page.server.ts`, `tests/unit/metrics.test.ts`. Add a shared metrics report type and range parser where both page and API can use them. Schema or collector changes are conditional on measured need.
2. Make refresh and report states dependable.
Retain server rendering for the initial page. Add a narrow read-only `/api/metrics?days=…` endpoint using the same report builder and range validation for background refresh. Use the existing `pollVisible` helper to avoid overlapping refreshes and refresh on return to a visible tab. The installed SvelteKit invalidation path can navigate into an error page; catching `invalidateAll()` alone does not guarantee preservation of the last successful report.
Store the last successful report, update it only after a successful response, and show refreshing, stale/error, and retry feedback inline. Give requests a timeout, abort on navigation/unmount, and use a request/scope identifier so an old response cannot overwrite a newly selected range. Keep range state in the URL. Preserve table search/sort and selected bucket through refresh; reconcile bucket selection when the range changes. Optional shareable table state should use shallow URL updates without rerunning aggregation. Avoid moving keyboard focus or announcing the entire report each minute.
Distinguish no retained samples, no samples in this selected range, unknown-only observations, unavailable capacity, and a failed report refresh. An empty range should offer a wider range. Do not promise that collection will begin if no servers are registered, or claim “first observations” solely because retention left the table empty.
Primary files: the metrics route, a new `src/routes/api/metrics/+server.ts`, and existing `src/lib/ui/poll.ts` usage. Keep shared-helper changes minimal.
3. Rebuild the summary and timeline around readable evidence.
Replace four equal cards with “Time with players” and “Player-hours.” Put the occupied numerator and known-time denominator beneath the percentage. Keep idle in the chart/table and known/unknown time in a compact strip. Show collection time separately from report refresh; display sparse-history caveats without inventing a confidence score.
Reuse `Stat` with numeric tweening disabled, existing cards/inputs/empty states, and semantic design tokens. Keep blue for occupied time, a quieter neutral for idle, and patterned unknown. Define chart colors as tokens rather than scattered literals. Use short-duration formatting so a real observation never appears as `0.0 h` just because it is brief. Keep player-hours and server-hours explicitly named.
Preserve absolute stacked server-hours as the default chart. Add three to five readable vertical ticks, intermediate time labels, marked partial edge buckets, and a persistent selected-period readout. The readout shows the exact interval in the selected timezone, occupied/idle/unknown server time, player-hours, and occupied percentage of known time. UTC-aligned buckets displayed in a local timezone must not be described as local calendar days.
Use a labelled native period selector and Previous/Next buttons for keyboard and touch access, with pointer selection as a convenience. Keep interactive controls and the readout outside any chart region exposed as `role="img"`. Keep the exact-data table with semantic headers. A bucket with no captured time needs an explicit “No captured time” readout; do not equate it with an idle fleet. Preserve selection by bucket key across minute refreshes.
On phones, keep the summary compact enough to reach the chart without scrolling through four tall cards. Allow controls to wrap and reduce tick density; do not remove units or denominators to make the view fit. Use a small metrics-specific component set rather than introducing a general dashboard framework or chart dependency.
4. Make the map and server rankings useful.
Keep both tables full width. Right-align numeric values, use tabular numerals, add keyboard-operable sort buttons with `aria-sort`, and define deterministic ties and missing-value placement. Keep the map default as player-hours descending. Give the server table an explicit default matching the displayed measure: occupied percentage descending, then known time, with unavailable percentages last.
Add restrained proportional player-hour bars to map rows. Keep pressure duration, its percentage, and known-capacity duration adjacent. Avoid severity colors or “add servers” recommendations without a defined policy and sufficient evidence. Known-capacity duration is a wall-time quantity, not server-hours.
Use friendly server names with raw identifiers available as secondary text. Preserve exact recorded game/map grouping and variants. Current collection stores friendly configured variants when resolvable; legacy engine-map strings can be ambiguous, so do not silently relabel them as one specific variant.
Add clearly labelled table-only search, starting with “Find a server”; it must not imply that the headline metrics or chart were filtered. Keep historical rows unlinked in the first release. A later explicitly labelled “Open current server” action could be added separately, but current registration alone cannot prove historical identity.
On narrow screens retain identity, the primary measure, and its denominator. Make full details available through a compact row disclosure or a labelled, keyboard-scrollable table region with sticky identity. Test which approach fits actual row lengths before choosing it. Empty searches offer a clear/reset action.
5. Validate the completed behavior and update usage documentation.
Extend calculation tests for new metadata and clipped intervals, then compare optimized results to the existing implementation across fixture scenarios. Test refresh errors, retry, visibility changes, overlapping refresh attempts, and responses arriving after a range switch. Prefer behavior checks over tests that duplicate markup.
Review no samples, a first sample with zero elapsed time, sparse history, all-unknown status, telemetry-only occupancy, missing capacity, nonzero durations under a minute, collection outages, changing fleet membership, partial edge buckets, timezone/DST presentation, removed/reused server names, and T6 variants. Verify sorting with unavailable percentages and equal values.
Inspect 390px, tablet, and desktop layouts; keyboard-only selection/sorting/table access; touch selection; readable focus states; reduced motion; and screen-reader descriptions. Ensure minute refresh preserves interaction state and background failures keep the last successful report visible. Benchmark 24-hour, seven-day, and 30-day queries against representative retained data, and check impact on concurrent application requests.
Run the relevant unit tests, `npm run check`, and `npm run build`. Update `docs/metrics.md` to match the labels, states, and counting rules. The review baseline passed the existing four metrics tests and 23 status/observation tests; that does not replace validation of the eventual implementation.
The team accepted two primary metrics, visible data-quality context, an absolute-hours chart, progressive detail, and small native interaction controls. The engineering review changed two early UX ideas: live historical row links are deferred because names can be reused, and “Last collected” replaces wording that could imply a fresh successful game observation. It also added background fetch isolation and measured query work to the implementation scope.
Defer a default percentage-only chart, fleet-wide “all full” KPI, period-over-period deltas, heatmaps, automatic hosting recommendations, and global game/map filters. A percentage-only chart can hide tiny denominators; map pressure intervals overlap and cannot be summed into a meaningful fleet total. Comparisons and heatmaps need fair coverage treatment. Global filters should follow later as a complete query scope applied consistently to summary, timeline, and both tables, with URL state.
Delivery can be split into three reviewable changes: (1) report metadata, measured query improvements, and calculation tests; (2) refresh handling plus summary/timeline and report states; (3) rankings, mobile/accessibility refinement, and documentation. The data contract comes first; frontend layout work can proceed against that agreed contract in parallel. The result should let an operator read utilization correctly, see its evidence limits, inspect a period, and find relevant maps or servers without treating missing data as zero.
+22
View File
@@ -3,6 +3,28 @@
Metrics is a primary navigation tab at `/metrics` (keyboard shortcut: `g m`).
Choose the past 24 hours, 7 days, or 30 days. The page refreshes every minute.
The overview shows **Time with players** (occupied server-hours divided by known
server-hours) and **Player-hours**. Known and unknown status time remain visible
alongside the totals. **Last collected** is the newest recorded batch, including
unknown checks; **Report refreshed** is when this report was calculated. Collection
stops when no servers are registered. Earliest retained observations do not prove
continuous history or the original installation date.
Background refresh keeps the last successful report visible on failure and offers
Retry. Returning to a visible tab refreshes immediately. The selected range is
stored in the URL, and refreshes preserve table controls and chart selection.
The timeline has labelled axes and marks partial edge periods. Select a period,
use Previous/Next, or select a bar to inspect its exact interval and values. The
time table supplies the same figures for comparison and assistive technology.
Zero captured time is distinct from an observed empty server. Short durations use
seconds or minutes so a real observation is not rounded to zero.
Map and server tables support numeric sorting and local search. Their searches
apply only to the named table. Known-capacity duration stays next to all-full time.
Server identities retain the technical name; rows describe history grouped by
name, which may include different instances if a name was reused.
- Utilized means a successful status response reported at least one player.
- Idle means a successful response reported zero players.
- Unknown covers failed checks, provisioning, and unavailable containers; it is excluded from utilization percentages.
+49
View File
@@ -0,0 +1,49 @@
export type MetricsDays = 1 | 7 | 30;
export function parseMetricsDays(value: string | null | undefined): MetricsDays {
const days = Number(value ?? 7);
return days === 1 || days === 7 || days === 30 ? days : 7;
}
/** All durations are hours. Observed is known server time, not collection coverage. */
export interface Utilization {
observed: number;
active: number;
idle: number;
unknown: number;
playerHours: number;
}
export interface ServerMetrics extends Utilization {
server: string;
}
export interface MapMetrics extends Utilization {
game: string;
map: string;
/** Map wall time, not summed server time. */
full: number;
capacityObserved: number;
}
export interface MetricPoint extends Utilization {
/** UTC bucket boundary, including when the selected interval starts later. */
at: number;
startAt: number;
endAt: number;
partial: boolean;
}
export interface MetricsData {
days: MetricsDays;
since: number;
now: number;
/** Retained sample timestamps describe collection, not gameplay freshness. */
firstRetainedSampleAt: number | null;
latestSampleAt: number | null;
registeredServerCount: number;
summary: Utilization;
servers: ServerMetrics[];
maps: MapMetrics[];
points: MetricPoint[];
}
+95 -48
View File
@@ -1,5 +1,8 @@
import { db } from '../db/index.js';
import { ONLINE_MAX_MS } from '../../server-freshness.js';
import type { MapMetrics, MetricPoint, MetricsData, MetricsDays, ServerMetrics, Utilization } from '../../metrics.js';
export type { MapMetrics, Utilization } from '../../metrics.js';
export interface MetricSample {
server: string;
@@ -18,61 +21,105 @@ export function recordMetrics(samples: MetricSample[], at = Date.now()): void {
})();
}
export interface Utilization {
observed: number;
active: number;
idle: number;
unknown: number;
playerHours: number;
}
export interface MapMetrics extends Utilization {
game: string;
map: string;
full: number;
capacityObserved: number;
function emptyUtilization(): Utilization {
return { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 };
}
/** Treat each observation as valid until the next poll, capped at the freshness limit.
* Longer collection gaps are excluded, never interpreted as empty servers. */
export function readMetrics(days: number, now = Date.now()) {
function addObservation(total: Utilization, players: number | null, hours: number): void {
if (players === null) total.unknown += hours;
else {
total.observed += hours;
if (players > 0) total.active += hours;
else if (players === 0) total.idle += hours;
total.playerHours += players * hours;
}
}
/** Each collection is a complete fleet snapshot. Hold it until the next collection,
* capped at the freshness limit; longer gaps contribute no observed or unknown time.
* Read the indexed history once and share the intervals across every aggregation. */
export function readMetrics(days: MetricsDays, now = Date.now()): MetricsData {
const since = now - days * 86400_000;
const bucketMs = days === 1 ? 3600_000 : 86400_000;
const handle = db();
const cte = `WITH times AS (
SELECT at, LEAD(at, 1, @now) OVER (ORDER BY at) AS next_at
FROM (SELECT DISTINCT at FROM metric_samples WHERE at >= @since - @fresh AND at <= @now)
), samples AS (
SELECT s.*, MAX(0, MIN(t.next_at, s.at + @fresh, @now) - MAX(s.at, @since)) / 3600000.0 AS hours
FROM metric_samples s JOIN times t ON t.at = s.at
)`;
const params = { now, since, fresh: ONLINE_MAX_MS };
const totals = `COALESCE(SUM(CASE WHEN players IS NOT NULL THEN hours ELSE 0 END), 0) AS observed,
COALESCE(SUM(CASE WHEN players > 0 THEN hours ELSE 0 END), 0) AS active,
COALESCE(SUM(CASE WHEN players = 0 THEN hours ELSE 0 END), 0) AS idle,
COALESCE(SUM(CASE WHEN players IS NULL THEN hours ELSE 0 END), 0) AS unknown,
COALESCE(SUM(players * hours), 0) AS playerHours`;
const summary = handle.prepare(`${cte} SELECT ${totals} FROM samples`).get(params) as Utilization;
const servers = handle.prepare(`${cte} SELECT server, ${totals} FROM samples GROUP BY server ORDER BY active DESC, server`).all(params) as (Utilization & { server: string })[];
const maps = handle.prepare(`${cte}, pressure AS (
SELECT game, map, at, MAX(hours) AS hours,
MIN(CASE WHEN players IS NOT NULL AND capacity > 0 THEN 1 ELSE 0 END) AS known,
MIN(CASE WHEN players >= capacity AND capacity > 0 THEN 1 ELSE 0 END) AS full
FROM samples GROUP BY game, map, at
), map_pressure AS (
SELECT game, map, SUM(CASE WHEN known = 1 THEN hours ELSE 0 END) AS capacityObserved,
SUM(CASE WHEN known = 1 AND full = 1 THEN hours ELSE 0 END) AS full
FROM pressure GROUP BY game, map
) SELECT s.game, s.map, ${totals}, p.full, p.capacityObserved
FROM samples s JOIN map_pressure p ON p.game = s.game AND p.map = s.map
GROUP BY s.game, s.map ORDER BY playerHours DESC, s.map`).all(params) as MapMetrics[];
// Split intervals at chart boundaries so a midnight poll cannot shift time into the wrong day.
const points: (Utilization & { at: number })[] = [];
const metadata = handle.prepare(`SELECT
(SELECT MIN(at) FROM metric_samples WHERE at <= @now) AS firstRetainedSampleAt,
(SELECT MAX(at) FROM metric_samples WHERE at <= @now) AS latestSampleAt,
(SELECT COUNT(*) FROM servers WHERE destroyed_at IS NULL) AS registeredServerCount`).get({ now }) as
Pick<MetricsData, 'firstRetainedSampleAt' | 'latestSampleAt' | 'registeredServerCount'>;
const summary = emptyUtilization();
const serverTotals = new Map<string, ServerMetrics>();
const mapTotals = new Map<string, Map<string, MapMetrics>>();
const points: MetricPoint[] = [];
for (let at = Math.floor(since / bucketMs) * bucketMs; at < now; at += bucketMs) {
const end = Math.min(at + bucketMs, now);
const start = Math.max(at, since);
const point = handle.prepare(`${cte} SELECT ${totals} FROM samples`)
.get({ ...params, since: start, now: end }) as Utilization;
points.push({ at, ...point });
points.push({ at, startAt: start, endAt: end, partial: start !== at || end !== at + bucketMs, ...emptyUtilization() });
}
return { days, since, now, summary, servers, maps, points };
type RecordedSample = MetricSample & { at: number };
let batch: RecordedSample[] = [];
function aggregateBatch(nextAt: number): void {
if (!batch.length) return;
const start = Math.max(batch[0].at, since);
const end = Math.min(nextAt, batch[0].at + ONLINE_MAX_MS, now);
const hours = Math.max(0, end - start) / 3600_000;
const pressure = new Map<MapMetrics, { known: boolean; full: boolean }>();
// Split once for the entire batch; observations crossing UTC hour/day
// boundaries contribute to both buckets without shifting their duration.
const slices: { point: MetricPoint; hours: number }[] = [];
for (let cursor = start; cursor < end;) {
const index = Math.floor((cursor - points[0].at) / bucketMs);
const point = points[index];
const stop = Math.min(end, point.endAt);
slices.push({ point, hours: (stop - cursor) / 3600_000 });
cursor = stop;
}
for (const sample of batch) {
let server = serverTotals.get(sample.server);
if (!server) {
server = { server: sample.server, ...emptyUtilization() };
serverTotals.set(sample.server, server);
}
let game = mapTotals.get(sample.game);
if (!game) { game = new Map(); mapTotals.set(sample.game, game); }
let map = game.get(sample.map);
if (!map) {
map = { game: sample.game, map: sample.map, full: 0, capacityObserved: 0, ...emptyUtilization() };
game.set(sample.map, map);
}
addObservation(summary, sample.players, hours);
addObservation(server, sample.players, hours);
addObservation(map, sample.players, hours);
for (const slice of slices) addObservation(slice.point, sample.players, slice.hours);
const known = sample.players !== null && sample.capacity !== null && sample.capacity > 0;
const full = known && sample.players! >= sample.capacity!;
const previous = pressure.get(map);
pressure.set(map, { known: known && (previous?.known ?? true), full: full && (previous?.full ?? true) });
}
for (const [map, state] of pressure) {
if (state.known) {
map.capacityObserved += hours;
if (state.full) map.full += hours;
}
}
}
type SampleRow = [at: number, server: string, game: string, map: string, players: number | null, capacity: number | null];
const samples = handle.prepare(`SELECT at, server, game, map, players, capacity FROM metric_samples
WHERE at >= ? AND at <= ? ORDER BY at`).raw().iterate(since - ONLINE_MAX_MS, now) as IterableIterator<SampleRow>;
for (const [at, server, game, map, players, capacity] of samples) {
if (batch.length && at !== batch[0].at) {
aggregateBatch(at);
batch = [];
}
batch.push({ at, server, game, map, players, capacity });
}
aggregateBatch(now);
const compareText = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
const servers = [...serverTotals.values()].sort((a, b) => b.active - a.active || compareText(a.server, b.server));
const maps = [...mapTotals.values()].flatMap(game => [...game.values()])
.sort((a, b) => b.playerHours - a.playerHours || compareText(a.map, b.map) || compareText(a.game, b.game));
return { days, since, now, ...metadata, summary, servers, maps, points };
}
@@ -0,0 +1,144 @@
<script lang="ts">
import type { MetricPoint, MetricsDays } from '$lib/metrics.js';
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
import Button from '$ui/components/primitives/Button.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Select from '$ui/components/primitives/Select.svelte';
let { points, days }: { points: MetricPoint[]; days: MetricsDays } = $props();
let selectedAt = $state<number | null>(null);
const selectedIndex = $derived.by(() => {
const existing = points.findIndex(point => point.at === selectedAt);
if (existing >= 0) return existing;
for (let i = points.length - 1; i >= 0; i--) if (!points[i].partial) return i;
return Math.max(0, points.length - 1);
});
const selected = $derived(points[selectedIndex]);
const selectedValue = $derived(selected ? String(selected.at) : '');
const ceiling = $derived.by(() => {
const max = Math.max(0, ...points.map(point => point.observed + point.unknown));
if (!max) return 1;
const magnitude = 10 ** Math.floor(Math.log10(max));
return Math.ceil(max / (magnitude / 2)) * (magnitude / 2);
});
const tick = new Intl.NumberFormat('en-US', { maximumSignificantDigits: 3 });
const ticks = $derived([1, .75, .5, .25, 0].map(fraction => ({ fraction, value: tick.format(ceiling * fraction) })));
const periodOptions = $derived(points.map(point => ({ value: String(point.at), label: `${$timeFormat.period(point.startAt)}${point.partial ? ' · partial' : ''}` })));
const middle = $derived(points[Math.floor(points.length / 2)]);
const captured = $derived(selected ? selected.observed + selected.unknown : 0);
function move(direction: number) {
const next = points[Math.max(0, Math.min(points.length - 1, selectedIndex + direction))];
if (next) selectedAt = next.at;
}
function chartKey(event: KeyboardEvent, index: number) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const next = event.key === 'Home' ? 0 : event.key === 'End' ? points.length - 1 : Math.max(0, Math.min(points.length - 1, index + (event.key === 'ArrowLeft' ? -1 : 1)));
selectedAt = points[next].at;
(event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLButtonElement>('button')[next]?.focus();
}
</script>
<section class="panel" aria-labelledby="metrics-trend-title">
<div class="heading"><h2 id="metrics-trend-title">Server time over time</h2><p>Server-hours per {days === 1 ? 'hour' : '24-hour period'}. Partial periods contain less elapsed time.</p></div>
<div class="legend"><span><i class="occupied"></i>With players</span><span><i class="idle"></i>Idle</span><span><i class="unknown"></i>Unknown</span><span class="partial-key">* Partial period</span></div>
<div class="axis-title">Server-hours</div>
<div class="chart-grid">
<div class="y-axis" aria-hidden="true">{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}>{item.value}</span>{/each}</div>
<div class="plot" role="group" aria-label="Server time by period. Select a bar or use the period selector below.">
<div class="grid-lines" aria-hidden="true">{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}></span>{/each}</div>
{#each points as point, index (point.at)}
<button class="bar" class:selected={point.at === selected?.at} class:partial={point.partial} type="button" tabindex={index === selectedIndex ? 0 : -1}
aria-pressed={point.at === selected?.at}
aria-label={`${$timeFormat.period(point.startAt)}${point.partial ? ', partial period' : ''}: ${metricDuration(point.active)} with players, ${metricDuration(point.idle)} idle, ${metricDuration(point.unknown)} unknown`}
title={`${$timeFormat.period(point.startAt)} – ${$timeFormat.period(point.endAt)}${point.partial ? ' · partial period' : ''}`}
onclick={() => { selectedAt = point.at; }} onkeydown={event => chartKey(event, index)}>
{#if point.partial}<span class="partial-marker" aria-hidden="true">*</span>{/if}
<span class="segment unknown" style:height={`${point.unknown / ceiling * 100}%`}></span>
<span class="segment idle" style:height={`${point.idle / ceiling * 100}%`}></span>
<span class="segment occupied" style:height={`${point.active / ceiling * 100}%`}></span>
</button>
{/each}
</div>
<div class="x-axis" aria-hidden="true"><span>{points[0] ? $timeFormat.period(points[0].startAt) : ''}</span>{#if middle}<span class="middle">{$timeFormat.period(middle.startAt)}</span>{/if}<span>{points.length ? $timeFormat.period(points[points.length - 1].endAt) : ''}</span></div>
</div>
{#if selected}
<div class="period-controls">
<div class="period-select"><Field label={`Inspect a period · ${$timeZone.replaceAll('_', ' ')}`}><Select value={selectedValue} options={periodOptions} onchange={event => { selectedAt = Number((event.currentTarget as HTMLSelectElement).value); }} /></Field></div>
<div class="step-buttons"><Button size="sm" disabled={selectedIndex === 0} onclick={() => move(-1)}>← Previous</Button><Button size="sm" disabled={selectedIndex === points.length - 1} onclick={() => move(1)}>Next →</Button></div>
</div>
<div class="readout" aria-label="Selected period details">
<p class="interval">{$timeFormat.period(selected.startAt)} – {$timeFormat.period(selected.endAt)} {#if selected.partial}<span class="partial-label">Partial period</span>{/if}</p>
{#if captured === 0}<p class="empty">No captured time in this period. Missing observations do not mean the fleet was idle.</p>
{:else}<dl>
<div><dt>With players</dt><dd>{metricDuration(selected.active)}<small>{metricPercent(selected.active, selected.observed)} of known time</small></dd></div>
<div><dt>Idle</dt><dd>{metricDuration(selected.idle)}</dd></div>
<div><dt>Unknown</dt><dd>{metricDuration(selected.unknown)}</dd></div>
<div><dt>Player-hours</dt><dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd></div>
</dl>{/if}
</div>
{/if}
<details class="time-data"><summary>View exact time data</summary>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
<div class="table-wrap" role="region" aria-label="Time data" tabindex="0"><table>
<caption>Periods in {$timeZone.replaceAll('_', ' ')}. Durations are estimated from captured observations.</caption>
<thead><tr><th scope="col">Period</th><th scope="col">With players</th><th scope="col">Idle</th><th scope="col">Unknown</th><th scope="col">Player-hours</th></tr></thead>
<tbody>{#each points as point (point.at)}<tr><th scope="row">{$timeFormat.period(point.startAt)}<small>to {$timeFormat.period(point.endAt)}{point.partial ? ' · Partial' : ''}</small></th><td>{metricDuration(point.active)}</td><td>{metricDuration(point.idle)}</td><td>{metricDuration(point.unknown)}</td><td>{point.observed ? metricNumber(point.playerHours) : '—'}</td></tr>{/each}</tbody>
</table></div>
</details>
</section>
<style>
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); min-width: 0; }
h2 { margin: 0; font-size: var(--text-lg); }
.heading p { margin: var(--space-2) 0 var(--space-4); color: var(--text-secondary); font-size: var(--text-sm); }
.legend { display: flex; gap: var(--space-4); flex-wrap: wrap; align-items: center; font-size: var(--text-xs); }
.legend span { display: flex; align-items: center; gap: 6px; }
.legend i { width: 10px; height: 10px; border-radius: 2px; }
.occupied { background: var(--chart-active); }
.idle { background: var(--chart-idle); }
.unknown { background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 3px, var(--chart-unknown-stripe) 3px 5px); }
.partial-key { color: var(--text-secondary); }
.axis-title { margin-top: var(--space-5); font-size: 11px; color: var(--text-tertiary); }
.chart-grid { display: grid; grid-template-columns: 44px minmax(0, 1fr); column-gap: 8px; margin-top: 12px; }
.y-axis { position: relative; text-align: right; font-size: 11px; color: var(--text-tertiary); height: 210px; }
.y-axis span { position: absolute; right: 0; transform: translateY(-50%); }
.plot { display: flex; position: relative; height: 210px; gap: 4px; isolation: isolate; border-bottom: 1px solid var(--border-strong); }
.grid-lines { position: absolute; inset: 0; pointer-events: none; z-index: -1; }
.grid-lines span { position: absolute; left: 0; right: 0; border-top: 1px solid var(--border-subtle); }
.bar { appearance: none; position: relative; flex: 1; min-width: 0; border: 0; margin: 0; padding: 0; background: transparent; display: flex; flex-direction: column; justify-content: flex-end; cursor: pointer; border-radius: 2px 2px 0 0; }
.bar.selected { outline: 1px solid var(--text-secondary); outline-offset: 2px; }
.bar:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 2px; }
.bar:hover { background: color-mix(in oklab, var(--accent-primary) 8%, transparent); }
.segment { display: block; width: 100%; flex-shrink: 0; pointer-events: none; }
.bar.partial { border-inline: 1px dashed var(--border-strong); }
.partial-marker { position: absolute; top: -17px; left: 50%; transform: translateX(-50%); font-size: 15px; color: var(--text-secondary); }
.x-axis { grid-column: 2; display: flex; justify-content: space-between; gap: 16px; color: var(--text-tertiary); font-size: 10px; margin-top: 10px; }
.x-axis span { max-width: 25ch; }
.x-axis span:last-child { text-align: right; }
.x-axis .middle { text-align: center; }
.period-controls { display: flex; align-items: flex-end; gap: var(--space-3); flex-wrap: wrap; margin-top: var(--space-5); }
.period-select { flex: 1 1 250px; max-width: 450px; }
.step-buttons { display: flex; gap: var(--space-2); padding-bottom: 3px; }
.readout { margin-top: var(--space-4); padding: var(--space-4); border-radius: var(--radius-md); background: var(--surface-sunken); border: 1px solid var(--border-subtle); }
.interval { color: var(--text-secondary); font-size: var(--text-xs); margin: 0; line-height: 1.6; }
.partial-label { display: inline-block; margin-left: 4px; padding: 1px 6px; border: 1px dashed var(--border-strong); border-radius: 4px; }
dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); margin: var(--space-3) 0 0; }
dt { color: var(--text-tertiary); font-size: var(--text-xs); }
dd { margin: var(--space-1) 0 0; font-variant-numeric: tabular-nums; font-size: var(--text-md); }
small { display: block; color: var(--text-tertiary); font-size: 11px; font-weight: normal; line-height: 1.5; }
.empty { margin: var(--space-3) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
details { margin-top: var(--space-4); font-size: var(--text-sm); }
summary { cursor: pointer; color: var(--text-secondary); padding: 4px 0; }
.table-wrap { max-width: 100%; overflow-x: auto; margin-top: var(--space-3); }
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
table { width: 100%; border-collapse: collapse; font-size: var(--text-xs); }
caption { text-align: left; color: var(--text-tertiary); margin-bottom: 8px; }
th, td { padding: 12px; border-bottom: 1px solid var(--border-subtle); text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
thead th { color: var(--text-secondary); }
th:first-child { text-align: left; }
tbody th { font-weight: var(--weight-regular); }
@media (max-width: 640px) { .panel { padding: var(--space-4); } .legend { gap: 10px; } .chart-grid { grid-template-columns: 32px minmax(0, 1fr); gap: 6px; } .plot { gap: 3px; } .x-axis .middle { display: none; } .x-axis { font-size: 11px; } dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } .readout { padding: var(--space-3); } }
</style>
@@ -0,0 +1,140 @@
<script lang="ts">
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import type { MapMetrics, ServerMetrics } from '$lib/metrics.js';
import { compareMetricValues, metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
import { serverTitle } from '$ui/server-labels.js';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
let { maps, servers }: { maps: MapMetrics[]; servers: ServerMetrics[] } = $props();
const mapColumns = [
{ key: 'map', label: 'Map' }, { key: 'playerHours', label: 'Player-hours' },
{ key: 'active', label: 'With players' }, { key: 'full', label: 'All lobbies full' },
{ key: 'capacityObserved', label: 'Known capacity' }
] as const;
const serverColumns = [
{ key: 'server', label: 'Server' }, { key: 'utilization', label: 'Time with players' },
{ key: 'idle', label: 'Idle' }, { key: 'observed', label: 'Known server time' }, { key: 'unknown', label: 'Unknown' }
] as const;
type MapSort = typeof mapColumns[number]['key'];
type ServerSort = typeof serverColumns[number]['key'];
type Direction = 'asc' | 'desc';
// Shallow navigation changes page.state, not page.url. Store the working
// search string there so filters react immediately and survive Back/Forward.
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
const params = $derived(new URLSearchParams(contextSearch));
const mapQuery = $derived((params.get('mapQ') ?? '').slice(0, 100));
const serverQuery = $derived((params.get('serverQ') ?? '').slice(0, 100));
const mapSort = $derived(mapColumns.find(column => column.key === params.get('mapSort'))?.key ?? 'playerHours');
const serverSort = $derived(serverColumns.find(column => column.key === params.get('serverSort'))?.key ?? 'utilization');
const mapDirection = $derived<Direction>(params.get('mapDirection') === 'asc' ? 'asc' : 'desc');
const serverDirection = $derived<Direction>(params.get('serverDirection') === 'asc' ? 'asc' : 'desc');
const maxPlayerHours = $derived(Math.max(0, ...maps.map(map => map.playerHours)));
const mapRows = $derived(maps.filter(map => `${map.game} ${gameName(map.game)} ${map.map}`.toLowerCase().includes(mapQuery.trim().toLowerCase())).sort((a, b) => {
const result = mapSort === 'map' ? a.map.localeCompare(b.map) * (mapDirection === 'asc' ? 1 : -1)
: compareMetricValues(mapValue(a, mapSort), mapValue(b, mapSort), mapDirection);
return result || a.map.localeCompare(b.map) || a.game.localeCompare(b.game);
}));
const serverRows = $derived(servers.filter(server => `${server.server} ${serverTitle(server.server)}`.toLowerCase().includes(serverQuery.trim().toLowerCase())).sort((a, b) => {
const result = serverSort === 'server' ? serverTitle(a.server).localeCompare(serverTitle(b.server)) * (serverDirection === 'asc' ? 1 : -1)
: compareMetricValues(serverValue(a, serverSort), serverValue(b, serverSort), serverDirection);
return result || b.observed - a.observed || a.server.localeCompare(b.server);
}));
function gameName(game: string) { return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase(); }
function mapValue(map: MapMetrics, key: Exclude<MapSort, 'map'>): number | null {
return key === 'full' && !map.capacityObserved || key === 'playerHours' && !map.observed ? null : map[key];
}
function serverValue(server: ServerMetrics, key: Exclude<ServerSort, 'server'>): number | null {
if (key === 'utilization' || key === 'idle') return server.observed > 0 ? server[key === 'utilization' ? 'active' : 'idle'] / server.observed : null;
return server[key];
}
function updateContext(changes: Record<string, string>) {
const url = new URL(page.url);
url.search = contextSearch;
for (const [key, value] of Object.entries(changes)) { if (value) url.searchParams.set(key, value); else url.searchParams.delete(key); }
replaceState(url, { ...page.state, metricsTableSearch: url.search });
}
function sortMap(key: MapSort) { updateContext({ mapSort: key, mapDirection: key === mapSort ? mapDirection === 'asc' ? 'desc' : 'asc' : key === 'map' ? 'asc' : 'desc' }); }
function sortServer(key: ServerSort) { updateContext({ serverSort: key, serverDirection: key === serverSort ? serverDirection === 'asc' ? 'desc' : 'asc' : key === 'server' ? 'asc' : 'desc' }); }
</script>
<section class="panel" aria-labelledby="map-metrics-title">
<div class="heading"><div><h2 id="map-metrics-title">Map play time & capacity</h2><p>Compare play time and how often every tracked lobby was full.</p></div>
<div class="search"><Field label="Find a map" hint="Search this table only."><Input type="search" value={mapQuery} placeholder="Map or game…" oninput={event => updateContext({ mapQ: (event.currentTarget as HTMLInputElement).value })} /></Field></div>
</div>
<p class="definition">“All lobbies full” uses map time when every tracked lobby has known occupancy and capacity. It does not measure failed joins or unmet demand.</p>
{#if mapRows.length}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
<div class="table-wrap" role="region" aria-label="Map play time and capacity table" tabindex="0"><table>
<thead><tr>{#each mapColumns as column}<th scope="col" aria-sort={mapSort === column.key ? mapDirection === 'asc' ? 'ascending' : 'descending' : undefined}><button type="button" onclick={() => sortMap(column.key)}>{column.label}<span aria-hidden="true">{mapSort === column.key ? mapDirection === 'asc' ? '↑' : '↓' : '↕'}</span></button></th>{/each}</tr></thead>
<tbody>{#each mapRows as map (JSON.stringify([map.game, map.map]))}<tr>
<th scope="row">{map.map}<small>{gameName(map.game)} · {map.game.toUpperCase()}</small></th>
<td class="player-hours"><span>{map.observed ? metricNumber(map.playerHours) : '—'}</span><div class="comparison" aria-hidden="true"><i style:width={`${maxPlayerHours ? map.playerHours / maxPlayerHours * 100 : 0}%`}></i></div></td>
<td>{metricDuration(map.active)}<small>server time</small></td>
<td>{#if map.capacityObserved > 0}{metricDuration(map.full)}<small>{metricPercent(map.full, map.capacityObserved)} during {metricDuration(map.capacityObserved)} known</small>{:else}<span class="unavailable">Unknown</span><small>No known capacity time</small>{/if}</td>
<td>{metricDuration(map.capacityObserved)}<small>map time</small></td>
</tr>{/each}</tbody>
</table></div>
{:else}<EmptyState title={maps.length ? 'No maps match this search' : 'No map observations'} description={maps.length ? 'Try a recorded map name or game.' : 'Map rows appear when server observations include a map.'}>{#snippet actions()}{#if mapQuery}<Button size="sm" onclick={() => updateContext({ mapQ: '' })}>Clear map search</Button>{/if}{/snippet}</EmptyState>{/if}
<div class="footer"><span>{mapRows.length} of {maps.length} maps</span><span>Missing player limits leave capacity unknown, even when occupancy is available.</span></div>
</section>
<section class="panel" aria-labelledby="server-metrics-title">
<div class="heading"><div><h2 id="server-metrics-title">Server utilization</h2><p>Time with players as a share of each server’s known time.</p></div>
<div class="search"><Field label="Find a server" hint="Search this table only."><Input type="search" value={serverQuery} placeholder="Server name or map…" oninput={event => updateContext({ serverQ: (event.currentTarget as HTMLInputElement).value })} /></Field></div>
</div>
{#if serverRows.length}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
<div class="table-wrap" role="region" aria-label="Server utilization table" tabindex="0"><table>
<thead><tr>{#each serverColumns as column}<th scope="col" aria-sort={serverSort === column.key ? serverDirection === 'asc' ? 'ascending' : 'descending' : undefined}><button type="button" onclick={() => sortServer(column.key)}>{column.label}<span aria-hidden="true">{serverSort === column.key ? serverDirection === 'asc' ? '↑' : '↓' : '↕'}</span></button></th>{/each}</tr></thead>
<tbody>{#each serverRows as server (server.server)}<tr>
<th scope="row"><ServerIdentity name={server.server} /><small class="server-id">{server.server}</small></th>
<td>{metricPercent(server.active, server.observed)}<small>{metricDuration(server.active)} of {metricDuration(server.observed)} known</small></td>
<td>{metricPercent(server.idle, server.observed)}<small>{metricDuration(server.idle)} idle</small></td>
<td>{metricDuration(server.observed)}</td><td>{metricDuration(server.unknown)}</td>
</tr>{/each}</tbody>
</table></div>
{:else}<EmptyState title="No servers match this search" description="Try a friendly map name or technical server ID.">{#snippet actions()}<Button size="sm" onclick={() => updateContext({ serverQ: '' })}>Clear server search</Button>{/snippet}</EmptyState>{/if}
<div class="footer"><span>{serverRows.length} of {servers.length} historical server names</span><span>A dash means no known time. Names may include more than one registration.</span></div>
</section>
<style>
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); min-width: 0; overflow: hidden; }
.heading { padding: var(--space-5); display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; }
h2 { font-size: var(--text-lg); margin: 0; }
.heading p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
.search { flex: 0 1 230px; min-width: 180px; }
.definition { margin: 0; padding: 0 var(--space-5) var(--space-4); color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.6; max-width: 110ch; }
.table-wrap { width: 100%; overflow-x: auto; }
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -3px; }
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
th, td { padding: 14px var(--space-4); border-bottom: 1px solid var(--border-subtle); text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
th:first-child { text-align: left; min-width: 185px; max-width: 280px; white-space: normal; overflow-wrap: anywhere; }
thead th { color: var(--text-secondary); background: var(--surface-sunken); font-size: var(--text-xs); }
thead button { display: inline-flex; justify-content: flex-end; align-items: center; gap: 6px; color: inherit; background: transparent; font: inherit; padding: 5px 0; border: 0; cursor: pointer; text-align: inherit; white-space: nowrap; }
thead button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 4px; }
thead button span { color: var(--text-tertiary); }
thead th[aria-sort] { color: var(--text-primary); }
tbody th { font-weight: var(--weight-medium); }
tbody tr:hover { background: color-mix(in oklab, var(--surface-raised) 35%, transparent); }
small { display: block; margin-top: 4px; font-size: 11px; font-weight: normal; line-height: 1.5; color: var(--text-tertiary); }
.server-id { font-family: var(--font-mono); font-size: 10px; }
.unavailable { color: var(--text-secondary); }
.player-hours { min-width: 120px; }
.comparison { height: 3px; width: 90px; margin: 8px 0 0 auto; background: var(--surface-raised); border-radius: 3px; }
.comparison i { display: block; height: 100%; background: var(--chart-active); border-radius: inherit; opacity: .7; }
.footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2) var(--space-4); padding: var(--space-4) var(--space-5); font-size: 11px; color: var(--text-tertiary); line-height: 1.6; }
@media (max-width: 640px) {
.heading { padding: var(--space-4); }
.search { flex: 1 1 100%; }
.definition { padding-inline: var(--space-4); }
th, td { padding: 12px; font-size: var(--text-xs); }
th:first-child { position: sticky; left: 0; z-index: 1; min-width: 130px; max-width: 150px; background: var(--surface); box-shadow: 1px 0 var(--border-strong); }
thead th:first-child { background: var(--surface-sunken); }
.footer { padding: var(--space-3) var(--space-4); }
}
</style>
+64
View File
@@ -0,0 +1,64 @@
import type { MetricsData } from '$lib/metrics.js';
interface Options {
getQuery: () => string;
onData: (report: MetricsData) => void;
onError: (message: string | null) => void;
onLoading: (loading: boolean) => void;
fetcher?: typeof fetch;
timeoutMs?: number;
}
/** Keeps background failures and obsolete responses out of the last successful report. */
export function createMetricsRefresh(options: Options) {
const request = options.fetcher ?? fetch;
let generation = 0;
let pending: { query: string; controller: AbortController; promise: Promise<void>; timer?: ReturnType<typeof setTimeout> } | null = null;
function cancel() {
generation++;
if (pending) {
clearTimeout(pending.timer);
pending.controller.abort();
pending = null;
}
options.onLoading(false);
}
function refresh(): Promise<void> {
const query = options.getQuery();
if (pending?.query === query) return pending.promise;
cancel();
const id = generation;
const controller = new AbortController();
let timedOut = false;
const current = () => generation === id && options.getQuery() === query;
options.onLoading(true);
options.onError(null);
const entry = { query, controller, promise: Promise.resolve(), timer: undefined as ReturnType<typeof setTimeout> | undefined };
pending = entry;
entry.timer = setTimeout(() => { timedOut = true; controller.abort(); }, options.timeoutMs ?? 15_000);
entry.promise = (async () => {
try {
const response = await request(`/api/metrics?${query}`, { signal: controller.signal, cache: 'no-store' });
if (!response.ok) throw new Error(`Report refresh failed (${response.status}).`);
const report = await response.json() as MetricsData;
if (!report || !Array.isArray(report.points) || !Array.isArray(report.maps) || !Array.isArray(report.servers) || !report.summary) {
throw new Error('The metrics response was incomplete.');
}
if (current()) options.onData(report);
} catch (error) {
if (current() && (!controller.signal.aborted || timedOut)) {
options.onError(timedOut ? 'Report refresh timed out. Try again.' : error instanceof Error ? error.message : 'Unable to refresh metrics. Try again.');
}
} finally {
clearTimeout(entry.timer);
if (pending === entry) pending = null;
if (current()) options.onLoading(false);
}
})();
return entry.promise;
}
return { refresh, cancel };
}
+32
View File
@@ -0,0 +1,32 @@
const numberFormat = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
export function metricNumber(value: number): string {
if (!Number.isFinite(value)) return '—';
if (value > 0 && value < 0.1) return '<0.1';
return numberFormat.format(value);
}
export function metricPercent(value: number, total: number): string {
if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return '—';
const percent = 100 * value / total;
return percent > 0 && percent < 0.1 ? '<0.1%' : `${percent.toFixed(1)}%`;
}
/** Input is hours; retain short, real observations instead of rounding them to zero. */
export function metricDuration(hours: number): string {
if (!Number.isFinite(hours) || hours < 0) return '—';
if (hours === 0) return '0 h';
const seconds = hours * 3600;
if (seconds < 1) return '<1 s';
if (seconds < 60) return `${numberFormat.format(seconds)} s`;
if (hours < 0.1) return `${numberFormat.format(seconds / 60)} min`;
return `${numberFormat.format(hours)} h`;
}
/** Missing evidence always sorts last, in either direction. */
export function compareMetricValues(left: number | null, right: number | null, direction: 'asc' | 'desc'): number {
const a = left !== null && Number.isFinite(left) ? left : null;
const b = right !== null && Number.isFinite(right) ? right : null;
if (a === null || b === null) return a === b ? 0 : a === null ? 1 : -1;
return (a - b) * (direction === 'asc' ? 1 : -1);
}
+6
View File
@@ -78,6 +78,12 @@
--accent-primary-hover: var(--blue-300);
--accent-primary-active: #398fcf;
/* Metrics: unknown uses a pattern as well as color. */
--chart-active: var(--accent-primary);
--chart-idle: #7b9baf;
--chart-unknown-base: #605b64;
--chart-unknown-stripe: #a7a0ad;
/* Typography — Inter everywhere, mono for data.
Display face lives inside BrandMark; no global --font-display token. */
--font-body: 'Inter', system-ui, -apple-system, sans-serif;
+9
View File
@@ -0,0 +1,9 @@
import { json } from '@sveltejs/kit';
import { parseMetricsDays } from '$lib/metrics.js';
import { readMetrics } from '$lib/server/fleet/metrics.js';
export function GET({ url }: { url: URL }) {
return json(readMetrics(parseMetricsDays(url.searchParams.get('days'))), {
headers: { 'cache-control': 'no-store' }
});
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { readMetrics } from '$lib/server/fleet/metrics.js';
import { parseMetricsDays } from '$lib/metrics.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ url }) => {
const requested = Number(url.searchParams.get('days') ?? 7);
return readMetrics([1, 7, 30].includes(requested) ? requested : 7);
return readMetrics(parseMetricsDays(url.searchParams.get('days')));
};
+123 -84
View File
@@ -1,109 +1,148 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { page } from '$app/state';
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte';
import { invalidateAll } from '$app/navigation';
import { pollVisible } from '$ui/poll.js';
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
import { createMetricsRefresh } from '$ui/metrics-refresh.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Card from '$ui/components/primitives/Card.svelte';
import Stat from '$ui/components/primitives/Stat.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import MetricsChart from '$ui/components/metrics/MetricsChart.svelte';
import MetricsTables from '$ui/components/metrics/MetricsTables.svelte';
import type { MetricsData } from '$lib/metrics.js';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const percent = (value: number, total: number) => total > 0 ? `${(100 * value / total).toFixed(1)}%` : '—';
const hours = (value: number) => `${value.toFixed(1)} h`;
const peak = $derived(Math.max(1, ...data.points.map(p => p.active + p.idle + p.unknown)));
const label = (at: number) => $timeFormat.period(at);
onMount(() => {
const timer = setInterval(() => { if (!document.hidden) void invalidateAll(); }, 60_000);
return () => clearInterval(timer);
let snapshot = $state<MetricsData | null>(null);
let refreshing = $state(false);
let refreshError = $state<string | null>(null);
const report = $derived(snapshot ?? data);
const captured = $derived(report.summary.observed + report.summary.unknown);
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
const refresh = createMetricsRefresh({
getQuery: () => new URLSearchParams({ days: String(data.days) }).toString(),
onData: value => { snapshot = value; },
onError: value => { refreshError = value; },
onLoading: value => { refreshing = value; }
});
$effect(() => {
// Navigation owns the new report; an older background request cannot replace it.
void data;
untrack(() => { refresh.cancel(); snapshot = null; refreshError = null; });
});
onMount(() => {
const stop = pollVisible(() => refresh.refresh(), 60_000, false);
return () => { stop(); refresh.cancel(); };
});
function rangeHref(days: number): string {
const params = new URLSearchParams(contextSearch);
params.set('days', String(days));
return `/metrics?${params}`;
}
</script>
<svelte:head><title>Metrics · Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Metrics" subtitle="Plan hosting around server utilization, map play time, and capacity pressure." />
<SectionHeader title="Metrics" subtitle="Understand server use, map play time, and measured capacity pressure.">
{#snippet actions()}<Button size="sm" loading={refreshing} onclick={() => refresh.refresh()}>Refresh</Button>{/snippet}
</SectionHeader>
<div class="toolbar">
<nav aria-label="Metrics time range">
{#each [1, 7, 30] as days}
<a href={`/metrics?days=${days}`} aria-current={data.days === days ? 'page' : undefined}>{days === 1 ? '24 hours' : `${days} days`}</a>
<a href={rangeHref(days)} aria-current={report.days === days ? 'page' : undefined} data-sveltekit-keepfocus data-sveltekit-noscroll>{days === 1 ? '24 hours' : `${days} days`}</a>
{/each}
</nav>
<span>Updated {$timeFormat.time(data.now)} · refreshes every minute</span>
<span class="range">{$timeFormat.period(report.since)} – {$timeFormat.period(report.now)}</span>
</div>
{#if data.servers.length === 0}
<section><h2>Collecting your first observations</h2><p>Metrics start with the next server status checks. Leave the manager running to build a history of busy hours and map demand.</p></section>
{:else}
<div class="stats">
<section><span>Servers utilized</span><strong>{percent(data.summary.active, data.summary.observed)}</strong><small>{hours(data.summary.active)} with players</small></section>
<section><span>Servers idle</span><strong>{percent(data.summary.idle, data.summary.observed)}</strong><small>{hours(data.summary.idle)} empty</small></section>
<section><span>Player time</span><strong>{hours(data.summary.playerHours)}</strong><small>Players × observed time</small></section>
<section><span>Unknown status</span><strong>{hours(data.summary.unknown)}</strong><small>Excluded from utilization percentages</small></section>
{#if refreshError}
<div class="refresh-error" role="alert">
<div><strong>Could not refresh metrics.</strong> {refreshError}<small>Showing the report from {$timeFormat.dateTime(report.now)}.</small></div>
<Button size="sm" loading={refreshing} onclick={() => refresh.refresh()}>Retry</Button>
</div>
<section>
<h2>Utilization over time</h2>
<p>Server-hours per {data.days === 1 ? 'hour' : '24-hour period'}. Period starts are shown in {$timeZone.replaceAll('_', ' ')}. Taller bars mean more time under observation.</p>
<div class="legend"><span><i class="active"></i>Utilized</span><span><i class="idle"></i>Idle</span><span><i class="unknown"></i>Unknown</span></div>
<div class="chart" role="img" aria-label="Stacked server-hours chart. Exact values are in the time data table below.">
{#each data.points as point}
<div class="bar" title={`${label(point.at)}: ${hours(point.active)} utilized, ${hours(point.idle)} idle, ${hours(point.unknown)} unknown`}>
<div class="unknown" style:height={`${point.unknown / peak * 100}%`}></div>
<div class="idle" style:height={`${point.idle / peak * 100}%`}></div>
<div class="active" style:height={`${point.active / peak * 100}%`}></div>
</div>
{/each}
</div>
<div class="axis"><span>{label(data.points[0].at)}</span><span>0–{hours(peak)} per bar</span><span>{label(data.points[data.points.length - 1].at)}</span></div>
<details><summary>View time data</summary><div class="table-wrap"><table><thead><tr><th>Period start ({$timeZone.replaceAll('_', ' ')})</th><th>Utilized</th><th>Idle</th><th>Unknown</th><th>Player time</th></tr></thead><tbody>{#each data.points as point}<tr><td>{label(point.at)}</td><td>{hours(point.active)}</td><td>{hours(point.idle)}</td><td>{hours(point.unknown)}</td><td>{hours(point.playerHours)}</td></tr>{/each}</tbody></table></div></details>
</section>
<section>
<h2>Map play time & capacity pressure</h2>
<p>Ranked by player-hours. “All lobbies full” measures time when every tracked lobby for a map has known capacity and is full. It indicates capacity pressure; failed joins and unmet demand are not tracked.</p>
<div class="table-wrap"><table>
<thead><tr><th>Map</th><th>Player time</th><th>Utilized server time</th><th>All lobbies full</th><th>Capacity coverage</th></tr></thead>
<tbody>{#each data.maps as map}<tr><td>{map.map}<small>{map.game.toUpperCase()}</small></td><td>{hours(map.playerHours)}</td><td>{hours(map.active)}</td><td>{map.capacityObserved > 0 ? `${hours(map.full)} (${percent(map.full, map.capacityObserved)})` : 'Unknown'}</td><td>{hours(map.capacityObserved)}</td></tr>{/each}</tbody>
</table></div>
<p class="note">Capacity coverage is map time with a known player limit and status for every tracked lobby. Servers that do not report a player limit cannot establish whether all lobbies are full.</p>
</section>
<section>
<h2>Server utilization</h2>
<div class="table-wrap"><table><thead><tr><th>Server</th><th>Utilized</th><th>Idle</th><th>Observed time</th><th>Unknown</th></tr></thead>
<tbody>{#each data.servers as server}<tr><td>{server.server}</td><td>{percent(server.active, server.observed)}</td><td>{percent(server.idle, server.observed)}</td><td>{hours(server.observed)}</td><td>{hours(server.unknown)}</td></tr>{/each}</tbody>
</table></div>
</section>
{/if}
<p class="note">Estimates use periodic server status observations. Each observation covers at most 45 seconds; longer collection gaps are excluded. History begins when collection is enabled and is retained for 31 days. Player time includes all players reported by the server.</p>
{#if captured > 0}
<div class="stats">
<Card>
<Stat label="Time with players" value={metricPercent(report.summary.active, report.summary.observed)} size="lg" tween={false} />
<p class="stat-context">{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} known server time</p>
{#if report.summary.observed === 0}<p class="stat-note">Occupancy is unknown for all captured time.</p>{/if}
</Card>
<Card>
<Stat label="Player-hours" value={report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'} size="lg" tween={false} />
<p class="stat-context">Players × known time · one player for one hour = 1</p>
</Card>
</div>
{/if}
<div class="context" aria-label="About this report">
{#if captured > 0}<span><strong>{metricDuration(report.summary.observed)}</strong> known server time</span><span><strong>{metricDuration(report.summary.unknown)}</strong> unknown status</span>{/if}
<span>Last collected <strong>{report.latestSampleAt === null ? 'No retained samples' : $timeFormat.dateTime(report.latestSampleAt)}</strong></span>
<span>Report refreshed {$timeFormat.time(report.now)} · refreshes every minute while visible</span>
{#if report.firstRetainedSampleAt !== null}<span>Earliest retained sample {$timeFormat.dateTime(report.firstRetainedSampleAt)}</span>{/if}
</div>
{#if captured === 0}
<Card>
<EmptyState title={report.latestSampleAt === null ? 'No retained observations yet' : report.servers.length > 0 ? 'Waiting for observed time' : 'No captured time in this range'}
description={report.latestSampleAt === null
? report.registeredServerCount > 0 ? 'Leave the manager running to collect server status and build a history.' : 'Register a server to begin collecting utilization history.'
: report.servers.length > 0 ? 'The first sample has arrived. Time builds between status checks; this report has no elapsed observations yet.'
: 'There are no elapsed observations in the selected window. Collection gaps are excluded from these estimates.'}>
{#snippet actions()}
{#if report.latestSampleAt !== null && report.days < 30}<Button href={rangeHref(30)} size="sm">View 30 days</Button>{/if}
{#if report.registeredServerCount === 0}<Button href="/" size="sm">View fleet</Button>{/if}
{/snippet}
</EmptyState>
</Card>
{:else}
{#if report.registeredServerCount === 0}
<p class="notice">No servers are currently registered. This report shows retained history; new samples resume when servers are registered.</p>
{:else if report.latestSampleAt !== null && report.now - report.latestSampleAt > 5 * 60_000}
<p class="notice">No new collection samples in the last five minutes. This report still includes the retained observations shown below.</p>
{/if}
{#if report.summary.observed === 0}<p class="notice">All captured server status is unknown. A dash means occupancy could not be calculated; it does not mean servers were empty.</p>{/if}
<MetricsChart points={report.points} days={report.days} />
<MetricsTables maps={report.maps} servers={report.servers} />
{/if}
<details class="methodology">
<summary>How these estimates work</summary>
<p>Time with players is occupied server time divided by known server time. Known time includes occupied and idle observations; unknown status and collection gaps are excluded from that percentage.</p>
<p>Each periodic observation covers at most 45 seconds. Longer collection gaps are excluded. History is retained for 31 days. Player-hours include all players reported by the server and are estimates, not individual session records.</p>
<p>Chart buckets are aligned to UTC, with labels shown in {$timeZone.replaceAll('_', ' ')}. Daily buckets are 24-hour periods, not local calendar days. The first and latest buckets can be partial.</p>
<p>Historical server rows are grouped by name and may include multiple registrations using that name. Map groups preserve the recorded game and map label.</p>
</details>
</div>
<style>
.page { display: grid; gap: var(--space-6); min-width: 0; }
section { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); min-width: 0; }
h2 { font-size: var(--text-lg); margin: 0 0 12px; }
p { color: var(--text-secondary); margin: 8px 0 16px; }
.toolbar, .axis, .legend { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.toolbar { align-items: center; }
.toolbar span, .axis, .note, small { color: var(--text-tertiary); font-size: var(--text-sm); }
nav { display: flex; gap: 8px; }
nav a { padding: 8px 12px; border-radius: var(--radius-md); text-decoration: none; }
.page { display: grid; gap: var(--space-5); min-width: 0; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
nav { display: flex; gap: var(--space-1); padding: 4px; border: 1px solid var(--border-subtle); background: var(--surface); border-radius: var(--radius-md); }
nav a { padding: 9px 12px; color: var(--text-secondary); border-radius: var(--radius-sm); text-decoration: none; white-space: nowrap; font-size: var(--text-sm); }
nav a[aria-current] { background: var(--surface-raised); color: var(--text-primary); }
.stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
.stats section { display: grid; gap: 8px; }
strong { font-size: 30px; font-variant-numeric: tabular-nums; }
small { display: block; }
.legend { justify-content: flex-start; font-size: var(--text-sm); }
.legend span { display: flex; align-items: center; gap: 6px; }
i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; }
.active { background: var(--accent-primary); }
.idle { background: #7b9baf; }
.unknown { background: repeating-linear-gradient(135deg, #605b64 0 3px, #a7a0ad 3px 5px); }
.chart { display: flex; height: 200px; gap: 4px; border-bottom: 1px solid var(--border-strong); margin: 20px 0 8px; }
.bar { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: flex-end; }
details { margin-top: 20px; } summary { cursor: pointer; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; text-align: left; font-size: var(--text-sm); }
th, td { padding: 12px; border-bottom: 1px solid var(--border-subtle); font-variant-numeric: tabular-nums; }
th { color: var(--text-secondary); white-space: nowrap; }
@media (max-width: 800px) { .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 450px) { .stats { grid-template-columns: 1fr; } }
nav a:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.range, .context, .methodology, .notice { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.6; }
.range { max-width: 50ch; }
.stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); }
.stat-context { margin: 10px 0 0; font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.5; }
.stat-note { margin: 6px 0 0; color: var(--text-tertiary); font-size: var(--text-xs); }
.context { display: flex; flex-wrap: wrap; column-gap: var(--space-5); row-gap: var(--space-1); padding: 0 2px; }
.context strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
.refresh-error { display: flex; justify-content: space-between; align-items: center; gap: var(--space-4); border: 1px solid var(--state-warning); padding: var(--space-4); border-radius: var(--radius-md); font-size: var(--text-sm); }
.refresh-error small { display: block; margin-top: var(--space-1); color: var(--text-secondary); }
.notice { margin: 0; border-left: 2px solid var(--state-warning); padding: var(--space-2) var(--space-3); color: var(--text-secondary); }
.methodology { padding: var(--space-3) 2px; }
.methodology summary { cursor: pointer; color: var(--text-secondary); }
.methodology p { max-width: 110ch; margin: var(--space-3) 0 0; }
@media (max-width: 540px) {
.page { gap: var(--space-4); }
.stats { gap: var(--space-2); }
.stats :global(.card) { padding: var(--space-3); }
.stats :global(.value) { font-size: var(--text-xl); }
.stats :global(.label) { font-size: 11px; }
.stat-context { font-size: var(--text-xs); }
.context { font-size: 11px; column-gap: var(--space-3); }
.refresh-error { align-items: flex-start; flex-wrap: wrap; }
}
</style>
+68
View File
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createMetricsRefresh } from '../../src/lib/ui/metrics-refresh.js';
afterEach(() => vi.useRealTimers());
const report = (days: number) => ({ days, summary: {}, points: [], maps: [], servers: [] });
function setup(fetcher: typeof fetch) {
let query = 'days=7';
const onData = vi.fn(), onError = vi.fn(), onLoading = vi.fn();
return { ...createMetricsRefresh({ getQuery: () => query, onData, onError, onLoading, fetcher, timeoutMs: 100 }),
onData, onError, onLoading, setQuery: (value: string) => { query = value; } };
}
describe('metrics background refresh', () => {
it('keeps the previous report after failure and accepts a successful retry', async () => {
const fetcher = vi.fn().mockResolvedValueOnce(new Response('', { status: 503 }))
.mockResolvedValueOnce(Response.json(report(7)));
const refresh = setup(fetcher);
await refresh.refresh();
expect(refresh.onData).not.toHaveBeenCalled();
expect(refresh.onError).toHaveBeenLastCalledWith('Report refresh failed (503).');
await refresh.refresh();
expect(refresh.onData).toHaveBeenLastCalledWith(report(7));
expect(refresh.onError).toHaveBeenLastCalledWith(null);
expect(refresh.onLoading).toHaveBeenLastCalledWith(false);
});
it('coalesces duplicate requests and ignores old replies even when transport ignores abort', async () => {
const resolve: ((r: Response) => void)[] = [];
const fetcher = vi.fn(() => new Promise<Response>(done => resolve.push(done)));
const refresh = setup(fetcher);
const old = refresh.refresh();
expect(refresh.refresh()).toBe(old);
expect(fetcher).toHaveBeenCalledTimes(1);
refresh.setQuery('days=1');
const next = refresh.refresh();
resolve[1](Response.json(report(1)));
await next;
resolve[0](Response.json(report(7)));
await old;
expect(refresh.onData).toHaveBeenCalledTimes(1);
expect(refresh.onData).toHaveBeenLastCalledWith(report(1));
});
it('suppresses errors from a cancelled request', async () => {
let reject!: (error: Error) => void;
const refresh = setup(vi.fn(() => new Promise<Response>((_, fail) => { reject = fail; })));
const pending = refresh.refresh();
refresh.cancel();
reject(new Error('Obsolete response'));
await pending;
expect(refresh.onData).not.toHaveBeenCalled();
expect(refresh.onError.mock.calls).toEqual([[null]]);
expect(refresh.onLoading).toHaveBeenLastCalledWith(false);
});
it('times out with retry feedback and clears loading', async () => {
vi.useFakeTimers();
const fetcher = vi.fn((_input, init) => new Promise<Response>((_, reject) => {
init?.signal?.addEventListener('abort', () => reject(new Error('aborted')));
})) as typeof fetch;
const refresh = setup(fetcher);
const pending = refresh.refresh();
await vi.advanceTimersByTimeAsync(100);
await pending;
expect(refresh.onError).toHaveBeenLastCalledWith('Report refresh timed out. Try again.');
expect(refresh.onLoading).toHaveBeenLastCalledWith(false);
});
});
+102 -1
View File
@@ -3,7 +3,7 @@ import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); });
afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); });
describe('historical metrics', () => {
it('weights occupancy by time and excludes failed checks and collection gaps', async () => {
@@ -56,4 +56,105 @@ describe('historical metrics', () => {
recordMetrics([sample], at + 32 * 86400_000);
expect(db().prepare('SELECT COUNT(*) AS n FROM metric_samples').get()).toEqual({ n: 1 });
});
it('distinguishes retained history, an empty range, and current collection targets', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const { db } = await import('../../src/lib/server/db/index.js');
const at = Date.UTC(2026, 0, 2);
expect(readMetrics(1, at)).toMatchObject({ firstRetainedSampleAt: null, latestSampleAt: null,
registeredServerCount: 0, servers: [], maps: [] });
db().prepare('INSERT INTO servers (name, komodo_name, destroyed_at) VALUES (?, ?, ?)').run('current', 'current', null);
db().prepare('INSERT INTO servers (name, komodo_name, destroyed_at) VALUES (?, ?, ?)').run('removed', 'removed', new Date(at).toISOString());
const sample = { server: 'removed', game: 't6', map: 'Town', players: 1, capacity: null };
recordMetrics([sample], at);
const emptyRange = readMetrics(1, at + 2 * 86400_000);
expect(emptyRange).toMatchObject({ firstRetainedSampleAt: at, latestSampleAt: at,
registeredServerCount: 1, servers: [], maps: [], summary: { observed: 0, unknown: 0 } });
expect(emptyRange.points.every(point => point.observed === 0 && point.unknown === 0)).toBe(true);
});
it('keeps unknown status separate from no samples and preserves zero elapsed observations', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 2, 12);
recordMetrics([{ server: 'one', game: 't5', map: 'Moon', players: null, capacity: null }], at);
const initial = readMetrics(1, at);
expect(initial.latestSampleAt).toBe(at);
expect(initial.servers).toHaveLength(1);
expect(initial.summary).toEqual({ observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 });
const later = readMetrics(1, at + 120_000);
expect(later.summary.observed).toBe(0);
expect(later.summary.unknown * 3600).toBeCloseTo(45);
expect(later.maps[0].capacityObserved).toBe(0);
});
it('returns clipped interval boundaries and marks only partial UTC buckets', async () => {
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 0, 8, 12, 30);
for (const days of [1, 7, 30] as const) {
const result = readMetrics(days, now);
expect(result.points[0].startAt).toBe(result.since);
expect(result.points.at(-1)?.endAt).toBe(now);
expect(result.points[0].partial).toBe(true);
expect(result.points.at(-1)?.partial).toBe(true);
expect(result.points.slice(1, -1).every(point => !point.partial)).toBe(true);
expect(result.points.reduce((ms, point) => ms + point.endAt - point.startAt, 0)).toBe(days * 86400_000);
}
expect(readMetrics(1, Date.UTC(2026, 0, 8, 12)).points.every(point => !point.partial)).toBe(true);
});
it('uses complete collection boundaries across fleet changes and keeps map pressure in wall time', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 2, 23, 59, 40);
const one = { server: 'one', game: 't6', map: 'Town', players: 4, capacity: 4 };
const two = { ...one, server: 'two' };
recordMetrics([one, two], at);
// Removing a lobby ends its previous observation at the next complete collection.
recordMetrics([{ ...one, map: 'Farm', players: 0 }], at + 30_000);
// A collection outage is excluded; an identically named map in another game remains separate.
recordMetrics([{ ...one, game: 't5', players: 2, capacity: null }], at + 120_000);
const result = readMetrics(1, at + 150_000);
expect(result.summary.active * 3600).toBeCloseTo(90);
expect(result.summary.idle * 3600).toBeCloseTo(45);
expect(result.summary.playerHours * 3600).toBeCloseTo(300);
expect(result.servers.find(server => server.server === 'two')!.observed * 3600).toBeCloseTo(30);
const town = result.maps.find(map => map.game === 't6' && map.map === 'Town')!;
expect(town.observed * 3600).toBeCloseTo(60);
expect(town.capacityObserved * 3600).toBeCloseTo(30);
expect(town.full * 3600).toBeCloseTo(30);
expect(result.maps.find(map => map.game === 't5')!.capacityObserved).toBe(0);
for (const field of ['observed', 'active', 'idle', 'unknown', 'playerHours'] as const) {
expect(result.points.reduce((sum, point) => sum + point[field], 0)).toBeCloseTo(result.summary[field]);
expect(result.servers.reduce((sum, server) => sum + server[field], 0)).toBeCloseTo(result.summary[field]);
expect(result.maps.reduce((sum, map) => sum + map[field], 0)).toBeCloseTo(result.summary[field]);
}
});
it('does not include future samples in collection metadata or aggregates', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 2, 12);
const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: 4 };
recordMetrics([sample], at);
recordMetrics([{ ...sample, players: 4 }], at + 60_000);
const result = readMetrics(1, at + 30_000);
expect(result.latestSampleAt).toBe(at);
expect(result.summary.playerHours * 3600).toBeCloseTo(30);
});
it('shares the range parser and response contract between SSR and the uncached refresh endpoint', async () => {
const { recordMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const { GET } = await import('../../src/routes/api/metrics/+server.js');
const { load } = await import('../../src/routes/metrics/+page.server.js');
const at = Date.UTC(2026, 0, 2, 12);
recordMetrics([{ server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 }], at);
vi.spyOn(Date, 'now').mockReturnValue(at + 30_000);
for (const range of ['1', '7', '30', 'invalid', '365', '-1', '']) {
const url = new URL(`http://localhost/metrics?days=${range}`);
const response = GET({ url });
const payload = await response.json();
expect(response.headers.get('cache-control')).toBe('no-store');
expect(payload).toEqual(await load({ url } as Parameters<typeof load>[0]));
expect(payload.days).toBe(['1', '7', '30'].includes(range) ? Number(range) : 7);
}
expect((await GET({ url: new URL('http://localhost/api/metrics') }).json()).days).toBe(7);
});
});
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { compareMetricValues, metricDuration, metricNumber, metricPercent } from '../../src/lib/ui/metrics.js';
describe('metric presentation', () => {
it('distinguishes unavailable evidence, zero, and tiny nonzero values', () => {
expect(metricPercent(0, 0)).toBe('—');
expect(metricPercent(0, 1)).toBe('0.0%');
expect(metricPercent(0.0001, 1)).toBe('<0.1%');
expect(metricDuration(0)).toBe('0 h');
expect(metricDuration(0.1 / 3600)).toBe('<1 s');
expect(metricDuration(45 / 3600)).toBe('45 s');
expect(metricDuration(90 / 3600)).toBe('1.5 min');
expect(metricNumber(0.0125)).toBe('<0.1');
});
it('sorts numerically while putting unavailable observations last in both directions', () => {
const values = [2, null, 100, 0, 10];
expect([...values].sort((a, b) => compareMetricValues(a, b, 'desc'))).toEqual([100, 10, 2, 0, null]);
expect([...values].sort((a, b) => compareMetricValues(a, b, 'asc'))).toEqual([0, 2, 10, 100, null]);
});
});