Add experimental metrics insights, comparisons, and scope filters

This commit is contained in:
2026-09-27 22:34:56 -04:00
parent 3922e28ca8
commit eb2a852fdc
18 changed files with 992 additions and 61 deletions
+8
View File
@@ -37,6 +37,14 @@ removal, and restart-recovery state appears alongside affected servers. Fleet
membership and operation progress refresh every ten seconds, with live server membership and operation progress refresh every ten seconds, with live server
status between refreshes. Failed refreshes keep the last snapshot visible. status between refreshes. Failed refreshes keep the last snapshot visible.
**Metrics** explores recorded server use by time range, game, and map. It includes
occupied/idle/unknown timelines, sortable rankings, previous-period comparisons,
map capacity pressure, and an activity heatmap in the selected timezone. Hosting
experiments suggest changes from explicit rules, including tentative advice from
sparse observations; they never change the fleet automatically. Background refresh
keeps the last report visible if it fails. See [Metrics](docs/metrics.md) for the
definitions, evidence limits, and experimental rules.
## Stack ## Stack
- SvelteKit (adapter-node) + TypeScript - SvelteKit (adapter-node) + TypeScript
+2
View File
@@ -105,3 +105,5 @@ The team accepted two primary metrics, visible data-quality context, an absolute
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. 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. 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.
After approval, the base was implemented and committed as `3922e28`. The user also authorized all previously deferred features as experiments. The panel reconvened after that commit; [the review and revised decisions](metrics-review.md) supersede the deferrals above while preserving explicit evidence and metric definitions.
+36
View File
@@ -0,0 +1,36 @@
The metrics panel reconvened after commit `3922e28` to review the implemented base and revisit the features deferred in the original plan. The user authorized proceeding with all of them, including experiments based on limited evidence.
The base passed 711 unit tests, Svelte checking with no errors or warnings, and a production build. Firefox checks exercised combined search/sort state, preservation across range changes, pointer and keyboard chart selection, failed background refresh with retained data, successful retry, and 390px layout without page overflow. These checks caught and fixed a shallow-URL reactivity issue before the commit. Calculation parity against the previous implementation held within `1e-7` on a 118,806-row synthetic fixture. Seven-day query time fell from approximately 1,369ms to 367–408ms in the uncontended comparison.
The post-commit engineering and UX reviews found no blocking issue. Two small follow-ups were accepted: neutral copy for samples with zero elapsed time, and a polite announcement when a user selects a chart period. Background refresh should not repeatedly announce the chart.
The team revisited the original objections as design constraints:
| Feature | Original concern | Agreed experiment |
| --- | --- | --- |
| Percentage chart | Normalization hides sample volume. | Default to share of captured server time, including unknown, with an Hours switch and exact durations in the selected-period readout. |
| Fleet pressure score | Map intervals overlap and cannot be added into elapsed fleet time. | Pool full map-hours divided by known-capacity map-hours; name the denominator explicitly and show how many map groups contribute. |
| Previous-period changes | Retained and observed history may differ substantially. | Show comparisons from available samples, including both denominators and a partial-history label. Utilization changes are percentage points. |
| Activity heatmap | Sparse observations can look like stable recurring demand. | Show duration-weighted occupancy by local weekday/hour, with known/unknown time, contributing dates, and no-data cells distinct from observed zero. |
| Hosting recommendations | Occupancy and saturation do not prove unmet demand. | Generate advisory, reversible hosting experiments from visible rules. Include evidence and tentative labels; never automatically create or remove servers. |
| Global filters | Inconsistent scopes would make the page misleading. | Apply exact game/map scope to every aggregate, comparison, heatmap, recommendation, and ranking; preserve collection boundaries before filtering. |
| Historical server links | A name may have been reused for a new instance. | Label the action “Open current server” and show it only when a current registration exists. Historical totals remain grouped by name. |
The agreed hierarchy keeps the two headline cards and core timeline. Compact scope controls sit near the time-range control. Pressure, comparisons, advisory experiments, and the secondary heatmap follow the timeline so the added features do not crowd out the primary task on mobile. Use neutral comparison colors, visible units, and concise evidence next to each recommendation.
The original plan remains as the record of the initial conservative scope. These decisions supersede its deferrals; limited data does not disable a feature when a defined calculation or explicitly tentative experiment is possible. An unavailable denominator still remains unavailable rather than becoming an invented number.
All seven additions were implemented. The expanded full suite passed 731 tests;
subsequent refresh-response hardening passed its focused tests, including a new
malformed-response case. Svelte checking reported no errors or warnings and the
production build passed. Firefox exercised the percentage/Hours switch with
preserved table state, current-registration links, whole-report game/map filters,
empty scopes, heatmap keyboard selection and timezone changes, saved preference
adoption, and explicit timezone precedence. The final mobile report fits at
390px without page overflow.
The combined 30-day report took 443–446ms on the seven-day, 118,806-row fixture.
A separate full-retention fixture with six servers and 535,680 rows took
1.96–3.30 seconds for the combined 30-day report. The queries still run
synchronously; that measured full-retention cost remains an improvement target.
These local synthetic timings are not production guarantees.
+55 -1
View File
@@ -14,6 +14,14 @@ Background refresh keeps the last successful report visible on failure and offer
Retry. Returning to a visible tab refreshes immediately. The selected range is Retry. Returning to a visible tab refreshes immediately. The selected range is
stored in the URL, and refreshes preserve table controls and chart selection. stored in the URL, and refreshes preserve table controls and chart selection.
Game and map filters apply to the whole report, including its comparisons,
recommendations, and heatmap. Map choices preserve the exact recorded game/map
pair. Range and scope are shareable through the URL. Table searches remain local.
The timeline defaults to **Share of captured time**: each stack includes occupied,
idle, and unknown server time, normalized to 100%. This denominator differs from the
headline utilization percentage, which excludes unknown time. Switch to **Hours**
to compare absolute server time. Periods without captured time remain gaps.
The timeline has labelled axes and marks partial edge periods. Select a period, 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 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. time table supplies the same figures for comparison and assistive technology.
@@ -23,7 +31,46 @@ seconds or minutes so a real observation is not rounded to zero.
Map and server tables support numeric sorting and local search. Their searches 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. 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 Server identities retain the technical name; rows describe history grouped by
name, which may include different instances if a name was reused. name, which may include different instances if a name was reused. **Open current
server** appears only when that name is currently registered; its destination
represents the current registration rather than every instance in the history.
**Explore this period** adds experimental decision aids below the main timeline:
- **Map capacity pressure** pools all-full map-hours divided by known-capacity
map-hours across the selected map groups. Overlapping map intervals are counted
separately. The score is not elapsed fleet time or a fleet health score; its
denominator and contributing map count remain visible.
- **Previous-period comparisons** use the preceding equally long window with
identical filters. Utilization changes are percentage points; player-hours
changes are relative percentages. A previous observed zero can show **New**.
Missing evidence remains unavailable. Partial retained history is labelled;
a 30-day comparison usually has little previous history because retention is
only 31 days. Both periods' known time is shown. Bounds cannot establish that
collection was uninterrupted, and changes are not adjusted for fleet size.
- **Activity heatmap** groups captured time by weekday and hour in its displayed
timezone. Color represents occupied / known server time. Exact cell values
include unknown time and contributing dates. No-data cells differ from observed
zero utilization; repeated DST hours contribute their actual elapsed time.
- **Hosting experiments** are generated automatically from the selected map
observations. They are advisory and never create, restart, or destroy a server.
Hosting Rules v1 deliberately offer experiments even with small samples:
| Recorded condition | Suggested experiment |
| --- | --- |
| At least 20% of known capacity time had every lobby full | Trial one additional lobby during busy hours. |
| Capacity unavailable and players present for at least 80% of known server time | Tentatively trial an extra lobby using occupancy as a proxy. |
| Players present for at most 10% of known time, with under 1% measured pressure or unavailable capacity | Review one idle lobby or a shorter hosting window while preserving access to the map. |
| Occupancy unavailable | Check telemetry/RCON reporting and watch for known player counts. |
| Other observations | Check missing capacity or keep the lineup and watch peaks. |
Each suggestion includes evidence and an outcome to watch. Unknown capacity,
less than one hour of relevant known evidence, or substantial unknown captured
status makes the suggestion tentative. These thresholds are product hypotheses;
occupancy does not establish failed joins or a need for additional capacity.
Suggestions use historical observations. Check the current lineup before trying
a change; a retained map may no longer have a hosted lobby.
- Utilized means a successful status response reported at least one player. - Utilized means a successful status response reported at least one player.
- Idle means a successful response reported zero players. - Idle means a successful response reported zero players.
@@ -36,3 +83,10 @@ Observations are recorded by the existing status worker. Each remains valid unti
Capacity comes from `sv_maxclients` / `sv_maxClients` in the status response. When unavailable, the UI displays unknown rather than assuming a four-player limit. Capacity coverage is the map time for which every tracked lobby reported both occupancy and capacity. Capacity comes from `sv_maxclients` / `sv_maxClients` in the status response. When unavailable, the UI displays unknown rather than assuming a four-player limit. Capacity coverage is the map time for which every tracked lobby reported both occupancy and capacity.
Collection begins after this feature is installed; previous occupancy cannot be reconstructed. Samples are retained for 31 days and pruned during collection. They survive removal of a server and contain no player identities. The additive database table is created automatically on startup. Collection begins after this feature is installed; previous occupancy cannot be reconstructed. Samples are retained for 31 days and pruned during collection. They survive removal of a server and contain no player identities. The additive database table is created automatically on startup.
`GET /api/metrics` supplies the same report as the page and disables response
caching. Query parameters are `days` (1, 7, or 30), `game`, `map` (requires game),
and `timeZone` (an IANA zone). Invalid ranges and zones fall back to the defaults;
invalid scope syntax is normalized. Fleet collection timestamps remain separate
from selected-scope evidence timestamps. Collection boundaries are determined
before scope filtering, so samples from other groups still end a batch interval.
+73
View File
@@ -0,0 +1,73 @@
import type { MapMetrics } from './metrics.js';
import { metricDuration, metricPercent } from './ui/metrics.js';
/** Product hypotheses for advisory experiments; these are not demand forecasts. */
export const HOSTING_RULES = {
pressureExpansion: 0.2,
occupancyExpansion: 0.8,
idleUtilization: 0.1,
quietPressure: 0.01,
minimumEvidenceHours: 1
} as const;
export interface MetricRecommendation {
game: string;
map: string;
kind: 'expand' | 'consolidate' | 'capacity' | 'watch';
title: string;
reason: string;
evidence: string;
outcome: string;
tentative: boolean;
}
/** Apply the same visible rules to all selected maps, including sparse observations. */
export function metricRecommendations(maps: MapMetrics[]): MetricRecommendation[] {
return maps.map(map => {
const utilization = map.observed > 0 ? map.active / map.observed : null;
const pressure = map.capacityObserved > 0 ? map.full / map.capacityObserved : null;
const knownShare = map.observed + map.unknown > 0 ? map.observed / (map.observed + map.unknown) : 0;
const base = {
game: map.game,
map: map.map,
evidence: `${metricDuration(map.active)} with players / ${metricDuration(map.observed)} known server time (${metricPercent(map.active, map.observed)}). ` +
(pressure === null ? 'Capacity unavailable.' : `All lobbies full ${metricDuration(map.full)} / ${metricDuration(map.capacityObserved)} known map time (${metricPercent(map.full, map.capacityObserved)}).`),
tentative: map.observed < HOSTING_RULES.minimumEvidenceHours || pressure === null || map.capacityObserved < HOSTING_RULES.minimumEvidenceHours || knownShare < 0.8
};
if (utilization === null) return {
...base, kind: 'capacity' as const, title: map.unknown > 0 ? 'Check occupancy reporting' : 'Let observations accumulate',
reason: map.unknown > 0
? 'Captured status is unknown. Check a server’s telemetry or RCON diagnostics before choosing a hosting change.'
: 'This map has no elapsed observations in the selected window. Wait for another status check or try a wider range.',
outcome: 'Watch for known server time in the next report.', tentative: true
};
if (pressure !== null && pressure >= HOSTING_RULES.pressureExpansion) return {
...base, kind: 'expand' as const, title: 'Trial one additional lobby',
reason: 'Recorded lobbies were all full for at least 20% of known capacity time. Try one extra lobby during busy hours.',
outcome: 'Compare all-full time and use of the additional lobby over the next comparable period.'
};
if (pressure === null && utilization >= HOSTING_RULES.occupancyExpansion) return {
...base, kind: 'expand' as const, title: 'Trial an extra lobby during busy hours',
reason: 'Players were present for at least 80% of known server time. Capacity is unknown, so this experiment uses occupancy as a proxy, not evidence of saturation.',
outcome: 'Check whether players use the extra lobby, and measure capacity if the server reports it.', tentative: true
};
if (utilization <= HOSTING_RULES.idleUtilization && (pressure === null || pressure < HOSTING_RULES.quietPressure)) return {
...base, kind: 'consolidate' as const, title: 'Trial less idle hosting',
reason: 'Players were present for at most 10% of known time, with less than 1% measured pressure or unavailable capacity. Review one idle lobby or a shorter hosting window; preserve access to this map.',
outcome: 'Watch for increased all-full time or loss of player-hours, and restore the lineup if the trial hurts availability.'
};
if (pressure === null) return {
...base, kind: 'capacity' as const, title: 'Check capacity during busy hours',
reason: 'Occupancy is recorded, but player limits are unavailable. Check capacity reporting while keeping the current lineup.',
outcome: 'Look for known-capacity time and all-full periods before the next hosting trial.'
};
return {
...base, kind: 'watch' as const, title: 'Keep the lineup and watch peaks',
reason: 'These observations do not trigger the expansion or idle-hosting rules. Keep the current lineup for another comparison period.',
outcome: 'Watch whether occupied time and measured capacity pressure change.'
};
}).sort((a, b) => {
const priority = { expand: 0, consolidate: 1, capacity: 2, watch: 3 };
return priority[a.kind] - priority[b.kind] || a.map.localeCompare(b.map) || a.game.localeCompare(b.game);
});
}
+60
View File
@@ -1,3 +1,5 @@
import { DEFAULT_TIME_ZONE, validTimeZone } from './time-zone.js';
export type MetricsDays = 1 | 7 | 30; export type MetricsDays = 1 | 7 | 30;
export function parseMetricsDays(value: string | null | undefined): MetricsDays { export function parseMetricsDays(value: string | null | undefined): MetricsDays {
@@ -5,6 +7,28 @@ export function parseMetricsDays(value: string | null | undefined): MetricsDays
return days === 1 || days === 7 || days === 30 ? days : 7; return days === 1 || days === 7 || days === 30 ? days : 7;
} }
export interface MetricsScope {
game: string | null;
map: string | null;
timeZone: string;
}
export interface MetricsQuery extends MetricsScope {
days: MetricsDays;
}
/** A map is an exact recorded label within a game, never a cross-game match. */
export function parseMetricsQuery(params: URLSearchParams): MetricsQuery {
const requestedGame = params.get('game')?.trim() ?? '';
const game = /^[a-z0-9_-]{1,32}$/.test(requestedGame) ? requestedGame : null;
const requestedMap = params.get('map');
const map = game && requestedMap && requestedMap.trim() && requestedMap.length <= 200 && !/[\u0000-\u001f\u007f]/.test(requestedMap)
? requestedMap : null;
const requestedZone = params.get('timeZone');
return { days: parseMetricsDays(params.get('days')), game, map,
timeZone: validTimeZone(requestedZone) ? requestedZone : DEFAULT_TIME_ZONE };
}
/** All durations are hours. Observed is known server time, not collection coverage. */ /** All durations are hours. Observed is known server time, not collection coverage. */
export interface Utilization { export interface Utilization {
observed: number; observed: number;
@@ -16,6 +40,8 @@ export interface Utilization {
export interface ServerMetrics extends Utilization { export interface ServerMetrics extends Utilization {
server: string; server: string;
/** A current registration with this name exists; historical identity may differ. */
currentRegistered: boolean;
} }
export interface MapMetrics extends Utilization { export interface MapMetrics extends Utilization {
@@ -34,6 +60,33 @@ export interface MetricPoint extends Utilization {
partial: boolean; partial: boolean;
} }
export interface PreviousMetrics {
since: number;
until: number;
summary: Utilization;
firstSampleAt: number | null;
lastSampleAt: number | null;
/** Retained scope evidence starts late or is absent, not a completeness guarantee. */
partialHistory: boolean;
}
export interface HeatmapCell extends Utilization {
/** Monday = 0, Sunday = 6. */
weekday: number;
hour: number;
/** Distinct local dates with any captured time, including unknown status. */
distinctDates: number;
}
export interface PooledPressure {
/** Share of known map-hours with every tracked lobby full, from 0 to 100. */
value: number | null;
fullMapHours: number;
knownMapHours: number;
coveredMaps: number;
totalMaps: number;
}
export interface MetricsData { export interface MetricsData {
days: MetricsDays; days: MetricsDays;
since: number; since: number;
@@ -42,8 +95,15 @@ export interface MetricsData {
firstRetainedSampleAt: number | null; firstRetainedSampleAt: number | null;
latestSampleAt: number | null; latestSampleAt: number | null;
registeredServerCount: number; registeredServerCount: number;
scope: MetricsScope;
scopeOptions: { games: string[]; maps: { game: string; map: string }[] };
scopeFirstSampleAt: number | null;
scopeLatestSampleAt: number | null;
summary: Utilization; summary: Utilization;
servers: ServerMetrics[]; servers: ServerMetrics[];
maps: MapMetrics[]; maps: MapMetrics[];
points: MetricPoint[]; points: MetricPoint[];
previous: PreviousMetrics;
heatmap: { timeZone: string; cells: HeatmapCell[] };
pooledPressure: PooledPressure;
} }
+94 -13
View File
@@ -1,6 +1,7 @@
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { ONLINE_MAX_MS } from '../../server-freshness.js'; import { ONLINE_MAX_MS } from '../../server-freshness.js';
import type { MapMetrics, MetricPoint, MetricsData, MetricsDays, ServerMetrics, Utilization } from '../../metrics.js'; import { parseMetricsQuery, type HeatmapCell, type MapMetrics, type MetricPoint, type MetricsData,
type MetricsDays, type MetricsQuery, type PreviousMetrics, type ServerMetrics, type Utilization } from '../../metrics.js';
export type { MapMetrics, Utilization } from '../../metrics.js'; export type { MapMetrics, Utilization } from '../../metrics.js';
@@ -38,16 +39,34 @@ function addObservation(total: Utilization, players: number | null, hours: numbe
/** Each collection is a complete fleet snapshot. Hold it until the next collection, /** 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. * 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. */ * Read the indexed history once and share the intervals across every aggregation. */
export function readMetrics(days: MetricsDays, now = Date.now()): MetricsData { export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now()): MetricsData {
const query = parseMetricsQuery(new URLSearchParams(typeof input === 'number' ? { days: String(input) }
: { days: String(input.days), game: input.game ?? '', map: input.map ?? '', timeZone: input.timeZone }));
const { days, ...scope } = query;
const since = now - days * 86400_000; const since = now - days * 86400_000;
const bucketMs = days === 1 ? 3600_000 : 86400_000; const bucketMs = days === 1 ? 3600_000 : 86400_000;
const handle = db(); const handle = db();
const metadata = handle.prepare(`SELECT // These small grouped results keep options and retained evidence independent
(SELECT MIN(at) FROM metric_samples WHERE at <= @now) AS firstRetainedSampleAt, // of the chosen scope/window without transferring a second raw history scan.
(SELECT MAX(at) FROM metric_samples WHERE at <= @now) AS latestSampleAt, const groups = handle.prepare(`SELECT game, map, MIN(at) AS first, MAX(at) AS latest
(SELECT COUNT(*) FROM servers WHERE destroyed_at IS NULL) AS registeredServerCount`).get({ now }) as FROM metric_samples WHERE at <= ? GROUP BY game, map ORDER BY game, map`).all(now) as
Pick<MetricsData, 'firstRetainedSampleAt' | 'latestSampleAt' | 'registeredServerCount'>; { game: string; map: string; first: number; latest: number }[];
const matchesScope = (sample: { game: string; map: string }) =>
(scope.game === null || sample.game === scope.game) && (scope.map === null || sample.map === scope.map);
const evidenceBounds = (values: typeof groups) => values.reduce<{ first: number | null; latest: number | null }>((bounds, group) => ({
first: bounds.first === null ? group.first : Math.min(bounds.first, group.first),
latest: bounds.latest === null ? group.latest : Math.max(bounds.latest, group.latest)
}), { first: null, latest: null });
const globalBounds = evidenceBounds(groups);
const scopedBounds = evidenceBounds(groups.filter(matchesScope));
const registered = new Set((handle.prepare('SELECT name FROM servers WHERE destroyed_at IS NULL').all() as { name: string }[])
.map(server => server.name));
const metadata = { firstRetainedSampleAt: globalBounds.first, latestSampleAt: globalBounds.latest,
registeredServerCount: registered.size, scopeFirstSampleAt: scopedBounds.first, scopeLatestSampleAt: scopedBounds.latest };
const scopeOptions = { games: [...new Set(groups.map(group => group.game))], maps: groups.map(({ game, map }) => ({ game, map })) };
const summary = emptyUtilization(); const summary = emptyUtilization();
const previous: PreviousMetrics = { since: since - days * 86400_000, until: since, summary: emptyUtilization(),
firstSampleAt: null, lastSampleAt: null, partialHistory: false };
const serverTotals = new Map<string, ServerMetrics>(); const serverTotals = new Map<string, ServerMetrics>();
const mapTotals = new Map<string, Map<string, MapMetrics>>(); const mapTotals = new Map<string, Map<string, MapMetrics>>();
const points: MetricPoint[] = []; const points: MetricPoint[] = [];
@@ -57,14 +76,61 @@ export function readMetrics(days: MetricsDays, now = Date.now()): MetricsData {
points.push({ at, startAt: start, endAt: end, partial: start !== at || end !== at + bucketMs, ...emptyUtilization() }); points.push({ at, startAt: start, endAt: end, partial: start !== at || end !== at + bucketMs, ...emptyUtilization() });
} }
const cells: HeatmapCell[] = Array.from({ length: 7 * 24 }, (_, index) => ({
weekday: Math.floor(index / 24), hour: index % 24, distinctDates: 0, ...emptyUtilization()
}));
const cellDates = cells.map(() => new Set<string>());
const calendar = new Intl.DateTimeFormat('en-US', { timeZone: scope.timeZone,
year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short', hour: 'numeric', hourCycle: 'h23' });
const weekdays: Record<string, number> = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 };
let cachedMinute = NaN;
let localCell = { index: 0, date: '' };
interface BatchCounts { known: number; active: number; idle: number; unknown: number; players: number }
function addHeatmap(counts: BatchCounts, start: number, end: number): void {
// Samples are at most 45 seconds. Splitting at UTC minute boundaries
// handles local hour/day boundaries, DST, and fractional-hour zones.
// Cache each minute once across the complete fleet collection batches.
for (let cursor = start; cursor < end;) {
const minute = Math.floor(cursor / 60_000);
if (minute !== cachedMinute) {
const parts = Object.fromEntries(calendar.formatToParts(cursor).map(part => [part.type, part.value]));
localCell = { index: weekdays[parts.weekday] * 24 + Number(parts.hour), date: `${parts.year}-${parts.month}-${parts.day}` };
cachedMinute = minute;
}
const stop = Math.min(end, (minute + 1) * 60_000);
const hours = (stop - cursor) / 3600_000;
const cell = cells[localCell.index];
cell.observed += counts.known * hours;
cell.active += counts.active * hours;
cell.idle += counts.idle * hours;
cell.unknown += counts.unknown * hours;
cell.playerHours += counts.players * hours;
cellDates[localCell.index].add(localCell.date);
cursor = stop;
}
}
type RecordedSample = MetricSample & { at: number }; type RecordedSample = MetricSample & { at: number };
let batch: RecordedSample[] = []; let batch: RecordedSample[] = [];
function aggregateBatch(nextAt: number): void { function aggregateBatch(nextAt: number): void {
if (!batch.length) return; if (!batch.length) return;
const start = Math.max(batch[0].at, since); const at = batch[0].at;
const end = Math.min(nextAt, batch[0].at + ONLINE_MAX_MS, now); const samples = scope.game === null ? batch : batch.filter(matchesScope);
if (!samples.length) return;
const end = Math.min(nextAt, at + ONLINE_MAX_MS, now);
const priorHours = Math.max(0, Math.min(end, previous.until) - Math.max(at, previous.since)) / 3600_000;
if (priorHours > 0) {
previous.firstSampleAt ??= at;
previous.lastSampleAt = at;
for (const sample of samples) addObservation(previous.summary, sample.players, priorHours);
}
// Preserve current-window zero-duration rows without bringing rows from
// the separate comparison window into the visible server/map tables.
if (at < since - ONLINE_MAX_MS) return;
const start = Math.max(at, since);
const hours = Math.max(0, end - start) / 3600_000; const hours = Math.max(0, end - start) / 3600_000;
const pressure = new Map<MapMetrics, { known: boolean; full: boolean }>(); const pressure = new Map<MapMetrics, { known: boolean; full: boolean }>();
const counts: BatchCounts = { known: 0, active: 0, idle: 0, unknown: 0, players: 0 };
// Split once for the entire batch; observations crossing UTC hour/day // Split once for the entire batch; observations crossing UTC hour/day
// boundaries contribute to both buckets without shifting their duration. // boundaries contribute to both buckets without shifting their duration.
const slices: { point: MetricPoint; hours: number }[] = []; const slices: { point: MetricPoint; hours: number }[] = [];
@@ -75,10 +141,10 @@ export function readMetrics(days: MetricsDays, now = Date.now()): MetricsData {
slices.push({ point, hours: (stop - cursor) / 3600_000 }); slices.push({ point, hours: (stop - cursor) / 3600_000 });
cursor = stop; cursor = stop;
} }
for (const sample of batch) { for (const sample of samples) {
let server = serverTotals.get(sample.server); let server = serverTotals.get(sample.server);
if (!server) { if (!server) {
server = { server: sample.server, ...emptyUtilization() }; server = { server: sample.server, currentRegistered: registered.has(sample.server), ...emptyUtilization() };
serverTotals.set(sample.server, server); serverTotals.set(sample.server, server);
} }
let game = mapTotals.get(sample.game); let game = mapTotals.get(sample.game);
@@ -92,6 +158,13 @@ export function readMetrics(days: MetricsDays, now = Date.now()): MetricsData {
addObservation(server, sample.players, hours); addObservation(server, sample.players, hours);
addObservation(map, sample.players, hours); addObservation(map, sample.players, hours);
for (const slice of slices) addObservation(slice.point, sample.players, slice.hours); for (const slice of slices) addObservation(slice.point, sample.players, slice.hours);
if (sample.players === null) counts.unknown++;
else {
counts.known++;
counts.players += sample.players;
if (sample.players > 0) counts.active++;
else if (sample.players === 0) counts.idle++;
}
const known = sample.players !== null && sample.capacity !== null && sample.capacity > 0; const known = sample.players !== null && sample.capacity !== null && sample.capacity > 0;
const full = known && sample.players! >= sample.capacity!; const full = known && sample.players! >= sample.capacity!;
const previous = pressure.get(map); const previous = pressure.get(map);
@@ -103,11 +176,12 @@ export function readMetrics(days: MetricsDays, now = Date.now()): MetricsData {
if (state.full) map.full += hours; if (state.full) map.full += hours;
} }
} }
addHeatmap(counts, start, end);
} }
type SampleRow = [at: number, server: string, game: string, map: string, players: number | null, capacity: number | null]; 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 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>; WHERE at >= ? AND at <= ? ORDER BY at`).raw().iterate(previous.since - ONLINE_MAX_MS, now) as IterableIterator<SampleRow>;
for (const [at, server, game, map, players, capacity] of samples) { for (const [at, server, game, map, players, capacity] of samples) {
if (batch.length && at !== batch[0].at) { if (batch.length && at !== batch[0].at) {
aggregateBatch(at); aggregateBatch(at);
@@ -121,5 +195,12 @@ export function readMetrics(days: MetricsDays, now = Date.now()): MetricsData {
const servers = [...serverTotals.values()].sort((a, b) => b.active - a.active || compareText(a.server, b.server)); const servers = [...serverTotals.values()].sort((a, b) => b.active - a.active || compareText(a.server, b.server));
const maps = [...mapTotals.values()].flatMap(game => [...game.values()]) 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)); .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 }; previous.partialHistory = scopedBounds.first === null || scopedBounds.first > previous.since || previous.firstSampleAt === null;
for (let index = 0; index < cells.length; index++) cells[index].distinctDates = cellDates[index].size;
const fullMapHours = maps.reduce((total, map) => total + map.full, 0);
const knownMapHours = maps.reduce((total, map) => total + map.capacityObserved, 0);
const pooledPressure = { value: knownMapHours > 0 ? 100 * fullMapHours / knownMapHours : null,
fullMapHours, knownMapHours, coveredMaps: maps.filter(map => map.capacityObserved > 0).length, totalMaps: maps.length };
return { days, since, now, ...metadata, scope, scopeOptions, summary, servers, maps, points, previous,
heatmap: { timeZone: scope.timeZone, cells }, pooledPressure };
} }
@@ -1,13 +1,19 @@
<script lang="ts"> <script lang="ts">
import type { MetricPoint, MetricsDays } from '$lib/metrics.js'; import type { MetricPoint, MetricsDays } from '$lib/metrics.js';
import { timeFormat, timeZone } from '$ui/stores/time-zone.js'; import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import { createTimeFormatter } from '$lib/time-zone.js';
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js'; import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import Field from '$ui/components/primitives/Field.svelte'; import Field from '$ui/components/primitives/Field.svelte';
import Select from '$ui/components/primitives/Select.svelte'; import Select from '$ui/components/primitives/Select.svelte';
let { points, days }: { points: MetricPoint[]; days: MetricsDays } = $props(); let { points, days, timeZone }: { points: MetricPoint[]; days: MetricsDays; timeZone: string } = $props();
let selectedAt = $state<number | null>(null); let selectedAt = $state<number | null>(null);
let announcement = $state('');
const format = $derived(createTimeFormatter(timeZone));
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
const mode = $derived(new URLSearchParams(contextSearch).get('chartMode') === 'hours' ? 'hours' : 'percent');
const selectedIndex = $derived.by(() => { const selectedIndex = $derived.by(() => {
const existing = points.findIndex(point => point.at === selectedAt); const existing = points.findIndex(point => point.at === selectedAt);
if (existing >= 0) return existing; if (existing >= 0) return existing;
@@ -17,34 +23,53 @@
const selected = $derived(points[selectedIndex]); const selected = $derived(points[selectedIndex]);
const selectedValue = $derived(selected ? String(selected.at) : ''); const selectedValue = $derived(selected ? String(selected.at) : '');
const ceiling = $derived.by(() => { const ceiling = $derived.by(() => {
if (mode === 'percent') return 100;
const max = Math.max(0, ...points.map(point => point.observed + point.unknown)); const max = Math.max(0, ...points.map(point => point.observed + point.unknown));
if (!max) return 1; if (!max) return 1;
const magnitude = 10 ** Math.floor(Math.log10(max)); const magnitude = 10 ** Math.floor(Math.log10(max));
return Math.ceil(max / (magnitude / 2)) * (magnitude / 2); return Math.ceil(max / (magnitude / 2)) * (magnitude / 2);
}); });
const tick = new Intl.NumberFormat('en-US', { maximumSignificantDigits: 3 }); 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 ticks = $derived([1, .75, .5, .25, 0].map(fraction => ({ fraction, value: `${tick.format(ceiling * fraction)}${mode === 'percent' ? '%' : ''}` })));
const periodOptions = $derived(points.map(point => ({ value: String(point.at), label: `${$timeFormat.period(point.startAt)}${point.partial ? ' · partial' : ''}` }))); const periodOptions = $derived(points.map(point => ({ value: String(point.at), label: `${format.period(point.startAt)}${point.partial ? ' · partial' : ''}` })));
const middle = $derived(points[Math.floor(points.length / 2)]); const middle = $derived(points[Math.floor(points.length / 2)]);
const captured = $derived(selected ? selected.observed + selected.unknown : 0); const captured = $derived(selected ? selected.observed + selected.unknown : 0);
function setMode(value: 'hours' | 'percent') {
const url = new URL(page.url);
url.search = contextSearch;
if (value === 'percent') url.searchParams.delete('chartMode'); else url.searchParams.set('chartMode', value);
replaceState(url, { ...page.state, metricsTableSearch: url.search });
}
function selectPeriod(at: number) {
selectedAt = at;
const point = points.find(item => item.at === at);
if (!point) return;
announcement = `${format.period(point.startAt)} to ${format.period(point.endAt)}${point.partial ? ', partial period' : ''}. ${point.observed + point.unknown ? `${metricDuration(point.active)} with players, ${metricDuration(point.idle)} idle, ${metricDuration(point.unknown)} unknown.` : 'No captured time.'}`;
}
function segmentHeight(point: MetricPoint, value: number) {
const denominator = mode === 'percent' ? point.observed + point.unknown : ceiling;
return denominator > 0 ? value / denominator * 100 : 0;
}
function move(direction: number) { function move(direction: number) {
const next = points[Math.max(0, Math.min(points.length - 1, selectedIndex + direction))]; const next = points[Math.max(0, Math.min(points.length - 1, selectedIndex + direction))];
if (next) selectedAt = next.at; if (next) selectPeriod(next.at);
} }
function chartKey(event: KeyboardEvent, index: number) { function chartKey(event: KeyboardEvent, index: number) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault(); 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))); 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; selectPeriod(points[next].at);
(event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLButtonElement>('button')[next]?.focus(); (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLButtonElement>('button')[next]?.focus();
} }
</script> </script>
<section class="panel" aria-labelledby="metrics-trend-title"> <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="heading"><div><h2 id="metrics-trend-title">Server time over time</h2><p>{mode === 'percent' ? 'Share of captured server time, including unknown status.' : `Server-hours per ${days === 1 ? 'hour' : '24-hour period'}.`} Partial periods contain less elapsed time.</p></div>
<div class="mode" role="group" aria-label="Timeline measure"><button type="button" aria-pressed={mode === 'percent'} onclick={() => setMode('percent')}>Percent</button><button type="button" aria-pressed={mode === 'hours'} onclick={() => setMode('hours')}>Hours</button></div>
</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="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="axis-title">{mode === 'percent' ? 'Share of captured server time' : 'Server-hours'}</div>
<div class="chart-grid"> <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="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="plot" role="group" aria-label="Server time by period. Select a bar or use the period selector below.">
@@ -52,40 +77,42 @@
{#each points as point, index (point.at)} {#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} <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-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`} aria-label={`${format.period(point.startAt)}${point.partial ? ', partial period' : ''}: ${point.observed + point.unknown ? `${metricDuration(point.active)} with players, ${metricDuration(point.idle)} idle, ${metricDuration(point.unknown)} unknown` : 'No captured time'}`}
title={`${$timeFormat.period(point.startAt)} – ${$timeFormat.period(point.endAt)}${point.partial ? ' · partial period' : ''}`} title={`${format.period(point.startAt)} – ${format.period(point.endAt)}${point.partial ? ' · partial period' : ''}`}
onclick={() => { selectedAt = point.at; }} onkeydown={event => chartKey(event, index)}> onclick={() => selectPeriod(point.at)} onkeydown={event => chartKey(event, index)}>
{#if point.partial}<span class="partial-marker" aria-hidden="true">*</span>{/if} {#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 unknown" style:height={`${segmentHeight(point, point.unknown)}%`}></span>
<span class="segment idle" style:height={`${point.idle / ceiling * 100}%`}></span> <span class="segment idle" style:height={`${segmentHeight(point, point.idle)}%`}></span>
<span class="segment occupied" style:height={`${point.active / ceiling * 100}%`}></span> <span class="segment occupied" style:height={`${segmentHeight(point, point.active)}%`}></span>
{#if point.observed + point.unknown === 0}<span class="no-time" aria-hidden="true">·</span>{/if}
</button> </button>
{/each} {/each}
</div> </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 class="x-axis" aria-hidden="true"><span>{points[0] ? format.period(points[0].startAt) : ''}</span>{#if middle}<span class="middle">{format.period(middle.startAt)}</span>{/if}<span>{points.length ? format.period(points[points.length - 1].endAt) : ''}</span></div>
</div> </div>
{#if selected} {#if selected}
<div class="period-controls"> <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="period-select"><Field label={`Inspect a period · ${timeZone.replaceAll('_', ' ')}`}><Select value={selectedValue} options={periodOptions} onchange={event => selectPeriod(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 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>
<div class="readout" aria-label="Selected period details"> <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> <p class="interval">{format.period(selected.startAt)} – {format.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> {#if captured === 0}<p class="empty">No captured time in this period. Missing observations do not mean the fleet was idle.</p>
{:else}<dl> {: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>With players</dt><dd>{metricDuration(selected.active)}<small>{metricPercent(selected.active, captured)} captured · {metricPercent(selected.active, selected.observed)} known</small></dd></div>
<div><dt>Idle</dt><dd>{metricDuration(selected.idle)}</dd></div> <div><dt>Idle</dt><dd>{metricDuration(selected.idle)}<small>{metricPercent(selected.idle, captured)} of captured time</small></dd></div>
<div><dt>Unknown</dt><dd>{metricDuration(selected.unknown)}</dd></div> <div><dt>Unknown</dt><dd>{metricDuration(selected.unknown)}<small>{metricPercent(selected.unknown, captured)} of captured time</small></dd></div>
<div><dt>Player-hours</dt><dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd></div> <div><dt>Player-hours</dt><dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd></div>
</dl>{/if} </dl><p class="denominator">{metricDuration(captured)} captured server time · {metricDuration(selected.observed)} known. Percentage bars include unknown time; the occupancy KPI excludes it.</p>{/if}
</div> </div>
{/if} {/if}
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
<details class="time-data"><summary>View exact time data</summary> <details class="time-data"><summary>View exact time data</summary>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) --> <!-- 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> <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> <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> <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> <tbody>{#each points as point (point.at)}<tr><th scope="row">{format.period(point.startAt)}<small>to {format.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> </table></div>
</details> </details>
</section> </section>
@@ -93,6 +120,11 @@
<style> <style>
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); min-width: 0; } .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); } h2 { margin: 0; font-size: var(--text-lg); }
.heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
.mode { display: flex; border: 1px solid var(--border-subtle); padding: 3px; border-radius: var(--radius-md); }
.mode button { border: 0; border-radius: var(--radius-sm); padding: 7px 10px; background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); cursor: pointer; }
.mode button[aria-pressed='true'] { background: var(--surface-raised); color: var(--text-primary); }
.mode button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.heading p { margin: var(--space-2) 0 var(--space-4); color: var(--text-secondary); font-size: var(--text-sm); } .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 { 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 span { display: flex; align-items: center; gap: 6px; }
@@ -113,6 +145,7 @@
.bar:focus-visible { outline: 2px solid var(--accent-primary-hover); 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); } .bar:hover { background: color-mix(in oklab, var(--accent-primary) 8%, transparent); }
.segment { display: block; width: 100%; flex-shrink: 0; pointer-events: none; } .segment { display: block; width: 100%; flex-shrink: 0; pointer-events: none; }
.no-time { color: var(--text-tertiary); font-size: 20px; line-height: 1; }
.bar.partial { border-inline: 1px dashed var(--border-strong); } .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); } .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 { grid-column: 2; display: flex; justify-content: space-between; gap: 16px; color: var(--text-tertiary); font-size: 10px; margin-top: 10px; }
@@ -130,6 +163,8 @@
dd { margin: var(--space-1) 0 0; font-variant-numeric: tabular-nums; font-size: var(--text-md); } 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; } 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); } .empty { margin: var(--space-3) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
.denominator { color: var(--text-tertiary); font-size: 11px; line-height: 1.5; margin: var(--space-3) 0 0; }
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
details { margin-top: var(--space-4); 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; } 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 { max-width: 100%; overflow-x: auto; margin-top: var(--space-3); }
@@ -0,0 +1,120 @@
<script lang="ts">
import type { MetricsData } from '$lib/metrics.js';
import { createTimeFormatter } from '$lib/time-zone.js';
import { HOSTING_RULES, metricRecommendations } from '$lib/metrics-insights.js';
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
import MetricsHeatmap from './MetricsHeatmap.svelte';
let { report, changingScope = false, onTimeZoneChange }: { report: MetricsData; changingScope?: boolean; onTimeZoneChange: (value: string) => void } = $props();
const format = $derived(createTimeFormatter(report.scope.timeZone));
const prior = $derived(report.previous);
const pressure = $derived(report.pooledPressure);
const recommendations = $derived(metricRecommendations(report.maps));
const utilizationDelta = $derived(report.summary.observed > 0 && prior.summary.observed > 0
? 100 * (report.summary.active / report.summary.observed - prior.summary.active / prior.summary.observed) : null);
const playerDelta = $derived(report.summary.observed <= 0 || prior.summary.observed <= 0 ? null
: prior.summary.playerHours > 0 ? 100 * (report.summary.playerHours / prior.summary.playerHours - 1)
: report.summary.playerHours === 0 ? 0 : null);
const playerNew = $derived(prior.summary.observed > 0 && report.summary.observed > 0 && prior.summary.playerHours === 0 && report.summary.playerHours > 0);
function signed(value: number, unit: string) { return `${value > 0 ? '+' : value < 0 ? '−' : ''}${metricNumber(Math.abs(value))}${unit}`; }
</script>
<section class="explore" aria-labelledby="metrics-explore-title">
<div class="heading"><h2 id="metrics-explore-title">Explore this period</h2><p>Planning signals for the selected scope. Review the evidence before changing hosting.</p></div>
<div class="signals">
<article class="signal" aria-labelledby="pressure-title">
<h3 id="pressure-title">Map capacity pressure</h3>
<strong class="value">{metricPercent(pressure.fullMapHours, pressure.knownMapHours)}</strong>
<p class="evidence">{metricDuration(pressure.fullMapHours)} full / {metricDuration(pressure.knownMapHours)} known map time</p>
<p>Share of pooled known map-hours with all lobbies full. {pressure.coveredMaps} of {pressure.totalMaps} map groups have known capacity.</p>
{#if pressure.knownMapHours === 0}<p class="notice">Capacity pressure is unavailable. Occupancy can still be recorded without a reported player limit.</p>{/if}
<p class="fine">Map groups can overlap in clock time; this is not simultaneous fleet saturation.</p>
</article>
<article class="signal" aria-labelledby="comparison-title">
<div class="comparison-heading"><h3 id="comparison-title">Compared with the previous {report.days === 1 ? '24 hours' : `${report.days} days`}</h3>{#if prior.partialHistory}<span class="badge">Partial previous history</span>{/if}</div>
<div class="deltas">
<div><span>Time with players</span><strong>{utilizationDelta === null ? '—' : signed(utilizationDelta, ' pp')}</strong><small>Percentage points · {metricPercent(prior.summary.active, prior.summary.observed)} → {metricPercent(report.summary.active, report.summary.observed)}</small></div>
<div><span>Player-hours</span><strong>{playerNew ? 'New' : playerDelta === null ? '—' : signed(playerDelta, '%')}</strong><small>{prior.summary.observed > 0 ? metricNumber(prior.summary.playerHours) : '—'} → {report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'}</small></div>
</div>
<p class="evidence">Known server time: {metricDuration(prior.summary.observed)} previous · {metricDuration(report.summary.observed)} current</p>
{#if prior.summary.observed <= 0 || report.summary.observed <= 0}<p class="notice">A comparison needs known occupancy in both windows. The available durations are still shown.</p>
{:else if prior.summary.playerHours === 0 && report.summary.playerHours > 0}<p class="notice">No previous player-hours: a percentage change has no baseline.</p>{/if}
{#if prior.partialHistory}<p class="fine">Retained scope history does not span the full previous window. Deltas use the available observations without extrapolation.</p>{/if}
<details class="comparison-detail"><summary>Compare observation windows</summary>
<dl><div><dt>Previous</dt><dd>{format.period(prior.since)} – {format.period(prior.until)}<small>{metricDuration(prior.summary.active)} with players / {metricDuration(prior.summary.observed)} known · {metricDuration(prior.summary.unknown)} unknown</small></dd></div>
<div><dt>Current</dt><dd>{format.period(report.since)} – {format.period(report.now)}<small>{metricDuration(report.summary.active)} with players / {metricDuration(report.summary.observed)} known · {metricDuration(report.summary.unknown)} unknown</small></dd></div></dl>
<p class="fine">pp means percentage points. Collection gaps, changing fleet size, and retained history can affect both comparisons. Equal windows do not guarantee equal observation coverage.</p>
</details>
</article>
</div>
<div class="suggestions">
<div class="suggestion-heading"><h3>Hosting suggestions</h3><span class="advisory">Advice only · Rules v1</span></div>
<p class="fine">Suggestions use historical observations. Check the current lineup before trying a change. These experiments do not estimate rejected joins or change servers automatically.</p>
{#if recommendations.length}
<div class="recommendations">{#each recommendations as suggestion (JSON.stringify([suggestion.game, suggestion.map]))}
<article class="recommendation">
<div class="map"><h4>{suggestion.map}</h4><small>{suggestion.game.toUpperCase()}</small>{#if suggestion.tentative}<span class="badge">Tentative</span>{/if}</div>
<div class="advice"><strong>{suggestion.title}</strong><p class="evidence">{suggestion.evidence}</p><details><summary>Why & what to watch</summary><p>{suggestion.reason}</p><p>{suggestion.outcome}</p></details></div>
</article>
{/each}</div>
{:else}<p class="empty">No map observations in this scope yet. Select another scope or leave collection running to generate suggestions.</p>{/if}
<details class="rules"><summary>How Rules v1 chooses a suggestion</summary>
<ul>
<li>Trial an additional lobby when all tracked lobbies are full for at least {HOSTING_RULES.pressureExpansion * 100}% of known-capacity time.</li>
<li>When capacity is unavailable, at least {HOSTING_RULES.occupancyExpansion * 100}% occupied known time can suggest a tentative additional-lobby trial. Occupancy does not establish saturation.</li>
<li>Review idle hosting when occupied known time is at most {HOSTING_RULES.idleUtilization * 100}%, and either measured pressure is below {HOSTING_RULES.quietPressure * 100}% or capacity is unavailable.</li>
<li>Missing occupancy prompts a reporting check. Other cases suggest watching hosting or checking capacity reporting.</li>
</ul>
<p>Less than {HOSTING_RULES.minimumEvidenceHours} hour of relevant known time, missing capacity, or substantial unknown status marks advice as tentative. These are trial policies, not validated demand forecasts. Collection gaps are omitted from all rules.</p>
</details>
</div>
<details class="heatmap-disclosure"><summary>View activity by weekday and hour</summary>
<MetricsHeatmap heatmap={report.heatmap} disabled={changingScope} {onTimeZoneChange} />
</details>
</section>
<style>
.explore { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
.heading { padding: var(--space-5) var(--space-5) var(--space-4); }
h2 { margin: 0; font-size: var(--text-lg); }
h3 { margin: 0; font-size: var(--text-md); }
p { margin: var(--space-2) 0 0; line-height: 1.6; font-size: var(--text-xs); color: var(--text-secondary); }
.heading p { font-size: var(--text-sm); }
.signals { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: var(--space-4); padding: 0 var(--space-5) var(--space-5); }
.signal { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: var(--space-4); background: var(--surface-sunken); }
.value { display: block; margin-top: var(--space-3); font-size: var(--text-2xl); font-variant-numeric: tabular-nums; }
.evidence { color: var(--text-primary); font-variant-numeric: tabular-nums; }
.fine { color: var(--text-tertiary); font-size: 11px; line-height: 1.6; }
.notice { padding-left: var(--space-2); border-left: 2px solid var(--state-warning); }
.comparison-heading { display: flex; gap: var(--space-2); align-items: flex-start; flex-wrap: wrap; }
.badge { display: inline-block; width: fit-content; color: var(--state-warning); font-size: 10px; border: 1px solid color-mix(in oklab, var(--state-warning) 40%, transparent); padding: 2px 6px; border-radius: 4px; white-space: nowrap; }
.deltas { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); margin-top: var(--space-3); }
.deltas span { color: var(--text-secondary); font-size: 11px; }
.deltas strong { display: block; margin-top: 4px; font-size: var(--text-xl); font-variant-numeric: tabular-nums; }
small { display: block; margin-top: 4px; font-size: 11px; color: var(--text-tertiary); font-weight: normal; line-height: 1.6; }
details { font-size: var(--text-xs); }
summary { cursor: pointer; color: var(--text-secondary); line-height: 1.6; }
.comparison-detail { margin-top: var(--space-3); }
dl { margin: var(--space-3) 0 0; display: grid; gap: var(--space-3); font-size: 11px; }
dt { color: var(--text-tertiary); }
dd { margin: 2px 0 0; color: var(--text-secondary); }
.suggestions { padding: var(--space-5); border-top: 1px solid var(--border-subtle); }
.suggestion-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
.advisory { color: var(--text-tertiary); font-size: 11px; }
.recommendations { margin-top: var(--space-3); }
.recommendation { display: grid; grid-template-columns: minmax(130px, .6fr) minmax(0, 2fr); gap: var(--space-4); padding: var(--space-4) 0; border-bottom: 1px solid var(--border-subtle); }
.map h4 { margin: 0; font-size: var(--text-sm); font-weight: var(--weight-medium); overflow-wrap: anywhere; }
.map .badge { margin-top: 5px; }
.advice strong { font-size: var(--text-sm); font-weight: var(--weight-medium); }
.advice p { margin-top: 5px; }
.advice details { margin-top: 6px; }
.rules { margin-top: var(--space-4); color: var(--text-secondary); }
.rules ul { padding-left: var(--space-5); line-height: 1.8; }
.empty { padding: var(--space-4) 0; }
.heatmap-disclosure { border-top: 1px solid var(--border-subtle); padding: var(--space-4) var(--space-5); }
.heatmap-disclosure > summary { font-size: var(--text-sm); font-weight: var(--weight-medium); }
@media (max-width: 760px) { .signals { grid-template-columns: 1fr; } }
@media (max-width: 540px) { .heading, .suggestions { padding: var(--space-4); } .signals { padding: 0 var(--space-4) var(--space-4); gap: var(--space-3); } .recommendation { grid-template-columns: 1fr; gap: var(--space-2); } .map { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; } .map small, .map .badge { margin: 0; } .heatmap-disclosure { padding: var(--space-4); } }
</style>
@@ -0,0 +1,104 @@
<script lang="ts">
import type { MetricsData } from '$lib/metrics.js';
import { availableTimeZones } from '$lib/time-zone.js';
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
import Field from '$ui/components/primitives/Field.svelte';
import Select from '$ui/components/primitives/Select.svelte';
type Heatmap = MetricsData['heatmap'];
type Cell = Heatmap['cells'][number];
let { heatmap, disabled = false, onTimeZoneChange }: { heatmap: Heatmap; disabled?: boolean; onTimeZoneChange: (value: string) => void } = $props();
const weekdays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const hours = Array.from({ length: 24 }, (_, hour) => hour);
const zones = $derived([...new Set([...availableTimeZones(), heatmap.timeZone])].sort().map(zone => ({ value: zone, label: zone.replaceAll('_', ' ') })));
const dayOptions = weekdays.map((label, day) => ({ value: String(day), label }));
const hourOptions = hours.map(hour => ({ value: String(hour), label: hourLabel(hour) }));
let selectedKey = $state<number | null>(null);
let announcement = $state('');
const cells = $derived(new Map(heatmap.cells.map(cell => [cell.weekday * 24 + cell.hour, cell])));
const selected = $derived(cells.get(selectedKey ?? -1) ?? heatmap.cells.find(cell => cell.observed + cell.unknown > 0) ?? heatmap.cells[0]);
function hourLabel(hour: number) { return `${String(hour).padStart(2, '0')}:00–${String((hour + 1) % 24).padStart(2, '0')}:00`; }
function cellAt(day: number, hour: number): Cell { return cells.get(day * 24 + hour) ?? { weekday: day, hour, active: 0, observed: 0, idle: 0, unknown: 0, playerHours: 0, distinctDates: 0 }; }
function description(cell: Cell) {
return `${weekdays[cell.weekday]}, ${hourLabel(cell.hour)}: ${cell.observed ? `${metricPercent(cell.active, cell.observed)} with players during ${metricDuration(cell.observed)} known server time` : cell.unknown ? `${metricDuration(cell.unknown)} unknown server time` : 'No captured time'}. ${cell.distinctDates} distinct dates.`;
}
function select(day: number, hour: number) { selectedKey = day * 24 + hour; announcement = description(cellAt(day, hour)); }
function move(event: KeyboardEvent, cell: Cell) {
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const day = Math.max(0, Math.min(6, cell.weekday + (event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0)));
const hour = event.key === 'Home' ? 0 : event.key === 'End' ? 23 : Math.max(0, Math.min(23, cell.hour + (event.key === 'ArrowLeft' ? -1 : event.key === 'ArrowRight' ? 1 : 0)));
const key = day * 24 + hour;
select(day, hour);
(event.currentTarget as HTMLElement).closest('table')?.querySelector<HTMLButtonElement>(`button[data-key="${key}"]`)?.focus();
}
</script>
<div class="heatmap">
<div class="heading"><div><h3>Activity by weekday and hour</h3><p>Time with players as a share of known server time, grouped in {heatmap.timeZone.replaceAll('_', ' ')}.</p></div>
<div class="zone"><Field label="Report timezone"><Select value={heatmap.timeZone} options={zones} {disabled} onchange={event => onTimeZoneChange((event.currentTarget as HTMLSelectElement).value)} /></Field></div>
</div>
<div class="legend"><span>0% <i class="scale"></i> 100% with players</span><span><i class="unknown-example"></i>Unknown only</span><span><i class="missing-example">·</i>No captured time</span></div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The heatmap table must scroll with the keyboard.) -->
<div class="table-wrap" role="region" aria-label="Activity heatmap; use arrow keys between cells or the selectors below" tabindex="0"><table>
<caption class="sr-only">Weekday and local hour activity. Cell details include known time and distinct recorded dates.</caption>
<thead><tr><th scope="col">Day</th>{#each hours as hour}<th scope="col">{String(hour).padStart(2, '0')}</th>{/each}</tr></thead>
<tbody>{#each weekdays as day, weekday}<tr><th scope="row">{day.slice(0, 3)}</th>{#each hours as hour}{@const cell = cellAt(weekday, hour)}
<td><button type="button" class="cell" class:known={cell.observed > 0} class:unknown={cell.observed === 0 && cell.unknown > 0} class:mixed={cell.observed > 0 && cell.unknown > 0} class:missing={cell.observed + cell.unknown === 0}
style:--activity={`${cell.observed > 0 ? Math.min(100, Math.max(0, 100 * cell.active / cell.observed)) : 0}%`}
data-key={weekday * 24 + hour} tabindex={selected?.weekday === weekday && selected?.hour === hour ? 0 : -1}
aria-pressed={selected?.weekday === weekday && selected?.hour === hour} aria-label={description(cell)} title={description(cell)}
onclick={() => select(weekday, hour)} onkeydown={event => move(event, cell)}><span aria-hidden="true">{cell.observed > 0 ? '' : cell.unknown > 0 ? '?' : '·'}</span></button></td>
{/each}</tr>{/each}</tbody>
</table></div>
<p class="note">A striped corner marks additional unknown time. Color uses known time only. Repeated daylight-saving clock hours share a cell; collection gaps stay uncaptured.</p>
{#if selected}
<div class="selectors"><Field label="Inspect weekday"><Select value={String(selected.weekday)} options={dayOptions} onchange={event => select(Number((event.currentTarget as HTMLSelectElement).value), selected.hour)} /></Field><Field label="Inspect hour"><Select value={String(selected.hour)} options={hourOptions} onchange={event => select(selected.weekday, Number((event.currentTarget as HTMLSelectElement).value))} /></Field></div>
<div class="readout"><strong>{weekdays[selected.weekday]} · {hourLabel(selected.hour)} · {heatmap.timeZone.replaceAll('_', ' ')}</strong>
{#if selected.observed + selected.unknown === 0}<p>No captured time for this weekday and hour. This does not mean the fleet was idle.</p>
{:else}<dl>
<div><dt>Time with players</dt><dd>{metricPercent(selected.active, selected.observed)}<small>{metricDuration(selected.active)} of {metricDuration(selected.observed)} known</small></dd></div>
<div><dt>Unknown server time</dt><dd>{metricDuration(selected.unknown)}</dd></div>
<div><dt>Player-hours</dt><dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd></div>
<div><dt>Distinct recorded dates</dt><dd>{selected.distinctDates}</dd></div>
</dl>{/if}
</div>
{/if}
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
</div>
<style>
.heatmap { min-width: 0; margin-top: var(--space-4); }
.heading { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-4); flex-wrap: wrap; }
h3 { margin: 0; font-size: var(--text-md); }
p { font-size: var(--text-xs); color: var(--text-secondary); margin: var(--space-2) 0 0; line-height: 1.6; }
.zone { flex: 0 1 250px; min-width: 190px; }
.legend { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; margin: var(--space-4) 0 var(--space-3); font-size: 11px; color: var(--text-secondary); }
.legend span { display: flex; gap: 5px; align-items: center; }
.scale { width: 64px; height: 9px; background: linear-gradient(to right, var(--surface-raised), var(--chart-active)); border-radius: 3px; }
.unknown-example, .missing-example { width: 14px; height: 14px; display: inline-flex; justify-content: center; align-items: center; font-style: normal; }
.unknown-example, .cell.unknown { background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 3px, var(--chart-unknown-stripe) 3px 5px); }
.missing-example { border: 1px dashed var(--border-control); }
.table-wrap { overflow-x: auto; max-width: 100%; padding: 3px; }
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
table { width: 100%; min-width: 750px; border-collapse: separate; border-spacing: 3px; table-layout: fixed; }
th { color: var(--text-tertiary); font-weight: normal; font-size: 10px; text-align: center; padding: 0 0 3px; }
th:first-child { width: 35px; position: sticky; left: 0; background: var(--surface); z-index: 2; text-align: left; }
td { padding: 0; }
.cell { position: relative; appearance: none; display: block; width: 100%; height: 29px; border: 1px solid var(--border-subtle); border-radius: 3px; background: var(--surface-raised); color: var(--text-secondary); font: inherit; cursor: pointer; overflow: hidden; }
.cell.known { background: color-mix(in oklab, var(--chart-active) var(--activity), var(--surface-raised)); }
.cell.missing { border-style: dashed; background: transparent; }
.cell.mixed::after { content: ''; position: absolute; top: 0; right: 0; width: 7px; height: 7px; background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 2px, var(--chart-unknown-stripe) 2px 4px); }
.cell[aria-pressed='true'] { outline: 1px solid var(--text-primary); outline-offset: 1px; }
.cell:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 1px; }
.note { color: var(--text-tertiary); font-size: 11px; }
.selectors { display: grid; grid-template-columns: repeat(2, minmax(0, 190px)); gap: var(--space-3); margin-top: var(--space-4); }
.readout { margin-top: var(--space-3); padding: var(--space-4); background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.readout > strong { font-size: var(--text-xs); font-weight: var(--weight-medium); color: var(--text-secondary); }
dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-3); margin: var(--space-3) 0 0; }
dt { font-size: 11px; color: var(--text-tertiary); }
dd { margin: 5px 0 0; font-size: var(--text-md); font-variant-numeric: tabular-nums; }
small { display: block; margin-top: 4px; font-size: 11px; color: var(--text-tertiary); }
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 640px) { .zone { flex-basis: 100%; } dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } .readout { padding: var(--space-3); } .selectors { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
</style>
@@ -92,14 +92,14 @@
<div class="table-wrap" role="region" aria-label="Server utilization table" tabindex="0"><table> <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> <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> <tbody>{#each serverRows as server (server.server)}<tr>
<th scope="row"><ServerIdentity name={server.server} /><small class="server-id">{server.server}</small></th> <th scope="row"><ServerIdentity name={server.server} /><small class="server-id">{server.server}</small>{#if server.currentRegistered}<a class="current-server" href={`/servers/${encodeURIComponent(server.server)}`}>Open current server <span aria-hidden="true">↗</span></a>{/if}</th>
<td>{metricPercent(server.active, server.observed)}<small>{metricDuration(server.active)} of {metricDuration(server.observed)} known</small></td> <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>{metricPercent(server.idle, server.observed)}<small>{metricDuration(server.idle)} idle</small></td>
<td>{metricDuration(server.observed)}</td><td>{metricDuration(server.unknown)}</td> <td>{metricDuration(server.observed)}</td><td>{metricDuration(server.unknown)}</td>
</tr>{/each}</tbody> </tr>{/each}</tbody>
</table></div> </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} {: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> <div class="footer"><span>{serverRows.length} of {servers.length} historical server names</span><span>Names may include multiple registrations. Links open the current registration, which may differ from historical instances.</span></div>
</section> </section>
<style> <style>
@@ -123,6 +123,9 @@
tbody tr:hover { background: color-mix(in oklab, var(--surface-raised) 35%, transparent); } 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); } 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; } .server-id { font-family: var(--font-mono); font-size: 10px; }
.current-server { display: inline-block; margin-top: 6px; font-size: 11px; color: var(--accent-primary-hover); text-decoration: none; }
.current-server:hover { text-decoration: underline; }
.current-server:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
.unavailable { color: var(--text-secondary); } .unavailable { color: var(--text-secondary); }
.player-hours { min-width: 120px; } .player-hours { min-width: 120px; }
.comparison { height: 3px; width: 90px; margin: 8px 0 0 auto; background: var(--surface-raised); border-radius: 3px; } .comparison { height: 3px; width: 90px; margin: 8px 0 0 auto; background: var(--surface-raised); border-radius: 3px; }
+25 -2
View File
@@ -1,4 +1,27 @@
import type { MetricsData } from '$lib/metrics.js'; import type { MetricsData } from '$lib/metrics.js';
import { validTimeZone } from '$lib/time-zone.js';
const record = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
const numeric = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
const timestamp = (value: unknown) => value === null || numeric(value);
const totals = (value: unknown) => record(value) && ['observed', 'active', 'idle', 'unknown', 'playerHours'].every(key => numeric(value[key]) && value[key] >= 0);
function validReport(value: unknown): value is MetricsData {
if (!record(value) || ![1, 7, 30].includes(value.days as number) || !numeric(value.now) || !numeric(value.since) || !totals(value.summary)) return false;
if (!record(value.scope) || !validTimeZone(value.scope.timeZone) || ![value.scope.game, value.scope.map].every(item => item === null || typeof item === 'string')) return false;
if (!record(value.scopeOptions) || !Array.isArray(value.scopeOptions.games) || !value.scopeOptions.games.every(item => typeof item === 'string') ||
!Array.isArray(value.scopeOptions.maps) || !value.scopeOptions.maps.every(item => record(item) && typeof item.game === 'string' && typeof item.map === 'string')) return false;
if (!numeric(value.registeredServerCount) || !['firstRetainedSampleAt', 'latestSampleAt', 'scopeFirstSampleAt', 'scopeLatestSampleAt'].every(key => timestamp(value[key]))) return false;
if (!Array.isArray(value.points) || !value.points.every(item => record(item) && totals(item) && numeric(item.at) && numeric(item.startAt) && numeric(item.endAt) && typeof item.partial === 'boolean')) return false;
if (!Array.isArray(value.maps) || !value.maps.every(item => record(item) && totals(item) && typeof item.game === 'string' && typeof item.map === 'string' && numeric(item.full) && numeric(item.capacityObserved))) return false;
if (!Array.isArray(value.servers) || !value.servers.every(item => record(item) && totals(item) && typeof item.server === 'string' && typeof item.currentRegistered === 'boolean')) return false;
if (!record(value.previous) || !totals(value.previous.summary) || !numeric(value.previous.since) || !numeric(value.previous.until) ||
!timestamp(value.previous.firstSampleAt) || !timestamp(value.previous.lastSampleAt) || typeof value.previous.partialHistory !== 'boolean') return false;
if (!record(value.heatmap) || !validTimeZone(value.heatmap.timeZone) || !Array.isArray(value.heatmap.cells) ||
!value.heatmap.cells.every(item => record(item) && totals(item) && numeric(item.weekday) && numeric(item.hour) && numeric(item.distinctDates))) return false;
return record(value.pooledPressure) && (value.pooledPressure.value === null || numeric(value.pooledPressure.value)) &&
['fullMapHours', 'knownMapHours', 'coveredMaps', 'totalMaps'].every(key => numeric((value.pooledPressure as Record<string, unknown>)[key]));
}
interface Options { interface Options {
getQuery: () => string; getQuery: () => string;
@@ -42,8 +65,8 @@ export function createMetricsRefresh(options: Options) {
try { try {
const response = await request(`/api/metrics?${query}`, { signal: controller.signal, cache: 'no-store' }); const response = await request(`/api/metrics?${query}`, { signal: controller.signal, cache: 'no-store' });
if (!response.ok) throw new Error(`Report refresh failed (${response.status}).`); if (!response.ok) throw new Error(`Report refresh failed (${response.status}).`);
const report = await response.json() as MetricsData; const report: unknown = await response.json();
if (!report || !Array.isArray(report.points) || !Array.isArray(report.maps) || !Array.isArray(report.servers) || !report.summary) { if (!validReport(report)) {
throw new Error('The metrics response was incomplete.'); throw new Error('The metrics response was incomplete.');
} }
if (current()) options.onData(report); if (current()) options.onData(report);
+2 -2
View File
@@ -1,9 +1,9 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { parseMetricsDays } from '$lib/metrics.js'; import { parseMetricsQuery } from '$lib/metrics.js';
import { readMetrics } from '$lib/server/fleet/metrics.js'; import { readMetrics } from '$lib/server/fleet/metrics.js';
export function GET({ url }: { url: URL }) { export function GET({ url }: { url: URL }) {
return json(readMetrics(parseMetricsDays(url.searchParams.get('days'))), { return json(readMetrics(parseMetricsQuery(url.searchParams)), {
headers: { 'cache-control': 'no-store' } headers: { 'cache-control': 'no-store' }
}); });
} }
+2 -2
View File
@@ -1,7 +1,7 @@
import { readMetrics } from '$lib/server/fleet/metrics.js'; import { readMetrics } from '$lib/server/fleet/metrics.js';
import { parseMetricsDays } from '$lib/metrics.js'; import { parseMetricsQuery } from '$lib/metrics.js';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ url }) => { export const load: PageServerLoad = ({ url }) => {
return readMetrics(parseMetricsDays(url.searchParams.get('days'))); return readMetrics(parseMetricsQuery(url.searchParams));
}; };
+79 -15
View File
@@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { timeFormat, timeZone } from '$ui/stores/time-zone.js'; import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { timeZone } from '$ui/stores/time-zone.js';
import { createTimeFormatter } from '$lib/time-zone.js';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js'; import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
import { createMetricsRefresh } from '$ui/metrics-refresh.js'; import { createMetricsRefresh } from '$ui/metrics-refresh.js';
@@ -10,7 +12,10 @@
import Stat from '$ui/components/primitives/Stat.svelte'; import Stat from '$ui/components/primitives/Stat.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import MetricsChart from '$ui/components/metrics/MetricsChart.svelte'; import MetricsChart from '$ui/components/metrics/MetricsChart.svelte';
import MetricsExplore from '$ui/components/metrics/MetricsExplore.svelte';
import MetricsTables from '$ui/components/metrics/MetricsTables.svelte'; import MetricsTables from '$ui/components/metrics/MetricsTables.svelte';
import type { MetricsData } from '$lib/metrics.js'; import type { MetricsData } from '$lib/metrics.js';
import type { PageData } from './$types'; import type { PageData } from './$types';
@@ -19,27 +24,73 @@
let snapshot = $state<MetricsData | null>(null); let snapshot = $state<MetricsData | null>(null);
let refreshing = $state(false); let refreshing = $state(false);
let refreshError = $state<string | null>(null); let refreshError = $state<string | null>(null);
let scopeError = $state('');
let scopeNavigating = $state(false);
let mounted = $state(false);
let attemptedPreference = $state<string | null>(null);
const report = $derived(snapshot ?? data); const report = $derived(snapshot ?? data);
const format = $derived(createTimeFormatter(report.scope.timeZone));
const captured = $derived(report.summary.observed + report.summary.unknown); const captured = $derived(report.summary.observed + report.summary.unknown);
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search); const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
const gameOptions = $derived([{ value: '', label: 'All games' }, ...[...new Set([...report.scopeOptions.games, ...(report.scope.game ? [report.scope.game] : [])])].map(game => ({ value: game, label: gameName(game) }))]);
const mapOptions = $derived([
{ value: '', label: 'All maps' },
...report.scopeOptions.maps.filter(map => !report.scope.game || map.game === report.scope.game).map(map => ({ value: JSON.stringify([map.game, map.map]), label: `${map.map}${report.scope.game ? '' : ` · ${gameName(map.game)}`}` })),
...(report.scope.map && !report.scopeOptions.maps.some(map => map.game === report.scope.game && map.map === report.scope.map) ? [{ value: JSON.stringify([report.scope.game, report.scope.map]), label: report.scope.map }] : [])
]);
const mapValue = $derived(report.scope.map ? JSON.stringify([report.scope.game, report.scope.map]) : '');
const scopeLabel = $derived(report.scope.map ? `${report.scope.map} · ${gameName(report.scope.game!)}` : report.scope.game ? gameName(report.scope.game) : 'All games and maps');
const refresh = createMetricsRefresh({ const refresh = createMetricsRefresh({
getQuery: () => new URLSearchParams({ days: String(data.days) }).toString(), getQuery: () => {
const params = new URLSearchParams({ days: String(data.days), timeZone: data.scope.timeZone });
if (data.scope.game) params.set('game', data.scope.game);
if (data.scope.map) params.set('map', data.scope.map);
return params.toString();
},
onData: value => { snapshot = value; }, onData: value => { snapshot = value; },
onError: value => { refreshError = value; }, onError: value => { refreshError = value; },
onLoading: value => { refreshing = value; } onLoading: value => { refreshing = value; }
}); });
beforeNavigate(() => { refresh.cancel(); scopeNavigating = true; });
afterNavigate(() => { scopeNavigating = false; attemptedPreference = null; });
$effect(() => { $effect(() => {
// Navigation owns the new report; an older background request cannot replace it. // Navigation owns the new report; an older background request cannot replace it.
void data; void data;
untrack(() => { refresh.cancel(); snapshot = null; refreshError = null; }); untrack(() => { refresh.cancel(); snapshot = null; refreshError = null; });
}); });
$effect(() => {
// Layout initializes the saved preference on mount; react when it arrives.
const preferredZone = $timeZone;
if (mounted && !scopeNavigating && !new URLSearchParams(contextSearch).has('timeZone') && preferredZone !== data.scope.timeZone && preferredZone !== attemptedPreference) {
untrack(() => { attemptedPreference = preferredZone; void changeScope({ timeZone: preferredZone }, true); });
}
});
onMount(() => { onMount(() => {
const stop = pollVisible(() => refresh.refresh(), 60_000, false); mounted = true;
const stop = pollVisible(() => scopeNavigating ? undefined : refresh.refresh(), 60_000, false);
return () => { stop(); refresh.cancel(); }; return () => { stop(); refresh.cancel(); };
}); });
function gameName(game: string) { return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase(); }
async function changeScope(changes: Record<string, string>, replace = false) {
const params = new URLSearchParams(contextSearch);
if (!params.has('timeZone')) params.set('timeZone', report.scope.timeZone);
for (const [key, value] of Object.entries(changes)) { if (value) params.set(key, value); else params.delete(key); }
refresh.cancel();
scopeError = '';
scopeNavigating = true;
try { await goto(`/metrics?${params}`, { keepFocus: true, noScroll: true, replaceState: replace }); }
catch { scopeError = 'Could not change the report scope. The previous report is still shown.'; }
finally { scopeNavigating = false; }
}
function changeMap(value: string) {
if (!value) return changeScope({ map: '' });
const [game, map] = JSON.parse(value) as [string, string];
return changeScope({ game, map });
}
function rangeHref(days: number): string { function rangeHref(days: number): string {
const params = new URLSearchParams(contextSearch); const params = new URLSearchParams(contextSearch);
params.set('days', String(days)); params.set('days', String(days));
params.set('timeZone', report.scope.timeZone);
return `/metrics?${params}`; return `/metrics?${params}`;
} }
</script> </script>
@@ -48,7 +99,7 @@
<div class="page"> <div class="page">
<SectionHeader title="Metrics" subtitle="Understand server use, map play time, and measured 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} {#snippet actions()}<Button size="sm" loading={refreshing} disabled={scopeNavigating} onclick={() => refresh.refresh()}>Refresh</Button>{/snippet}
</SectionHeader> </SectionHeader>
<div class="toolbar"> <div class="toolbar">
<nav aria-label="Metrics time range"> <nav aria-label="Metrics time range">
@@ -56,11 +107,17 @@
<a href={rangeHref(days)} aria-current={report.days === days ? 'page' : undefined} data-sveltekit-keepfocus data-sveltekit-noscroll>{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} {/each}
</nav> </nav>
<span class="range">{$timeFormat.period(report.since)} – {$timeFormat.period(report.now)}</span> <span class="range">{format.period(report.since)} – {format.period(report.now)}</span>
</div> </div>
<div class="scope-controls" aria-label="Filter the entire metrics report">
<Field label="Game"><Select value={report.scope.game ?? ''} options={gameOptions} disabled={scopeNavigating} onchange={event => changeScope({ game: (event.currentTarget as HTMLSelectElement).value, map: '' })} /></Field>
<Field label="Map"><Select value={mapValue} options={mapOptions} disabled={scopeNavigating} onchange={event => changeMap((event.currentTarget as HTMLSelectElement).value)} /></Field>
<p class="scope-note" role="status">{scopeNavigating ? 'Updating report…' : `${scopeLabel} · applies to every metric, chart, and table`}</p>
</div>
{#if scopeError}<p class="notice" role="alert">{scopeError}</p>{/if}
{#if refreshError} {#if refreshError}
<div class="refresh-error" role="alert"> <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> <div><strong>Could not refresh metrics.</strong> {refreshError}<small>Showing the report from {format.dateTime(report.now)}.</small></div>
<Button size="sm" loading={refreshing} onclick={() => refresh.refresh()}>Retry</Button> <Button size="sm" loading={refreshing} onclick={() => refresh.refresh()}>Retry</Button>
</div> </div>
{/if} {/if}
@@ -79,19 +136,21 @@
{/if} {/if}
<div class="context" aria-label="About this report"> <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} {#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>Scope last collected <strong>{report.scopeLatestSampleAt === null ? 'No retained samples' : format.dateTime(report.scopeLatestSampleAt)}</strong></span>
<span>Report refreshed {$timeFormat.time(report.now)} · refreshes every minute while visible</span> <span>Fleet last collected <strong>{report.latestSampleAt === null ? 'No retained samples' : format.dateTime(report.latestSampleAt)}</strong></span>
{#if report.firstRetainedSampleAt !== null}<span>Earliest retained sample {$timeFormat.dateTime(report.firstRetainedSampleAt)}</span>{/if} <span>Report refreshed {format.time(report.now)} · refreshes every minute while visible</span>
{#if report.scopeFirstSampleAt !== null}<span>Scope retained samples from {format.dateTime(report.scopeFirstSampleAt)}</span>{/if}
</div> </div>
{#if captured === 0} {#if captured === 0}
<Card> <Card>
<EmptyState title={report.latestSampleAt === null ? 'No retained observations yet' : report.servers.length > 0 ? 'Waiting for observed time' : 'No captured time in this range'} <EmptyState title={report.latestSampleAt === null ? 'No retained observations yet' : report.servers.length > 0 ? 'Waiting for observed time' : 'No captured time in this scope'}
description={report.latestSampleAt === null 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.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.' : report.servers.length > 0 ? 'Captured samples have no elapsed time in this window. Time builds between status checks.'
: 'There are no elapsed observations in the selected window. Collection gaps are excluded from these estimates.'}> : 'There are no elapsed observations for the selected game, map, and time window. Try another scope or a wider range.'}>
{#snippet actions()} {#snippet actions()}
{#if report.latestSampleAt !== null && report.days < 30}<Button href={rangeHref(30)} size="sm">View 30 days</Button>{/if} {#if report.latestSampleAt !== null && report.days < 30}<Button href={rangeHref(30)} size="sm">View 30 days</Button>{/if}
{#if report.scope.game || report.scope.map}<Button size="sm" onclick={() => changeScope({ game: '', map: '' })}>View all games</Button>{/if}
{#if report.registeredServerCount === 0}<Button href="/" size="sm">View fleet</Button>{/if} {#if report.registeredServerCount === 0}<Button href="/" size="sm">View fleet</Button>{/if}
{/snippet} {/snippet}
</EmptyState> </EmptyState>
@@ -103,14 +162,15 @@
<p class="notice">No new collection samples in the last five minutes. This report still includes the retained observations shown below.</p> <p class="notice">No new collection samples in the last five minutes. This report still includes the retained observations shown below.</p>
{/if} {/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} {#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} /> <MetricsChart points={report.points} days={report.days} timeZone={report.scope.timeZone} />
<MetricsTables maps={report.maps} servers={report.servers} />
{/if} {/if}
<MetricsExplore {report} changingScope={scopeNavigating} onTimeZoneChange={value => changeScope({ timeZone: value })} />
{#if captured > 0}<MetricsTables maps={report.maps} servers={report.servers} />{/if}
<details class="methodology"> <details class="methodology">
<summary>How these estimates work</summary> <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>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>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>Timeline buckets are aligned to UTC, with labels shown in {report.scope.timeZone.replaceAll('_', ' ')}. Daily buckets are 24-hour periods, not local calendar days. The first and latest buckets can be partial. Percentage bars divide occupied, idle, and unknown time by captured time; the headline occupancy percentage uses known time only.</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> <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> </details>
</div> </div>
@@ -118,6 +178,8 @@
<style> <style>
.page { display: grid; gap: var(--space-5); min-width: 0; } .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; } .toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
.scope-controls { display: grid; grid-template-columns: minmax(140px, 220px) minmax(180px, 300px) minmax(140px, 1fr); gap: var(--space-3); align-items: end; }
.scope-note { color: var(--text-secondary); font-size: var(--text-xs); margin: 0 0 8px; line-height: 1.5; }
nav { display: flex; gap: var(--space-1); padding: 4px; border: 1px solid var(--border-subtle); background: var(--surface); border-radius: var(--radius-md); } 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 { 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); } nav a[aria-current] { background: var(--surface-raised); color: var(--text-primary); }
@@ -136,6 +198,8 @@
.methodology summary { cursor: pointer; color: var(--text-secondary); } .methodology summary { cursor: pointer; color: var(--text-secondary); }
.methodology p { max-width: 110ch; margin: var(--space-3) 0 0; } .methodology p { max-width: 110ch; margin: var(--space-3) 0 0; }
@media (max-width: 540px) { @media (max-width: 540px) {
.scope-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.scope-note { grid-column: 1 / -1; margin: 0; }
.page { gap: var(--space-4); } .page { gap: var(--space-4); }
.stats { gap: var(--space-2); } .stats { gap: var(--space-2); }
.stats :global(.card) { padding: var(--space-3); } .stats :global(.card) { padding: var(--space-3); }
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { metricRecommendations } from '../../src/lib/metrics-insights.js';
import type { MapMetrics } from '../../src/lib/metrics.js';
const map = (values: Partial<MapMetrics> = {}): MapMetrics => ({
game: 't5', map: 'Moon', observed: 10, active: 5, idle: 5, unknown: 0,
playerHours: 10, full: 0, capacityObserved: 5, ...values
});
describe('advisory hosting experiments', () => {
it('uses map capacity time rather than summed server time for expansion', () => {
const [result] = metricRecommendations([map({ observed: 100, active: 50, idle: 50, full: 1, capacityObserved: 4 })]);
expect(result.kind).toBe('expand');
expect(result.evidence).toContain('25.0%');
expect(result.tentative).toBe(false);
});
it('offers a tentative trial even with a tiny recorded sample', () => {
const [result] = metricRecommendations([map({ observed: 1 / 120, active: 1 / 120, idle: 0, full: 1 / 120, capacityObserved: 1 / 120 })]);
expect(result.kind).toBe('expand');
expect(result.tentative).toBe(true);
expect(result.evidence).toContain('30 s');
});
it('uses explicitly tentative occupancy advice when capacity is missing', () => {
const [result] = metricRecommendations([map({ active: 8, idle: 2, capacityObserved: 0 })]);
expect(result.kind).toBe('expand');
expect(result.tentative).toBe(true);
expect(result.reason).toContain('not evidence of saturation');
});
it('does not confuse unknown occupancy with an idle fleet', () => {
const [result] = metricRecommendations([map({ observed: 0, active: 0, idle: 0, unknown: 10, playerHours: 0, capacityObserved: 0 })]);
expect(result.kind).toBe('capacity');
expect(result.title).toBe('Check occupancy reporting');
expect(result.tentative).toBe(true);
});
it('keeps advice scoped to input maps and considers idle trials with incomplete capacity', () => {
const [result] = metricRecommendations([map({ game: 't6', map: 'Town', active: 1, idle: 9, capacityObserved: 0 })]);
expect(result).toMatchObject({ game: 't6', map: 'Town', kind: 'consolidate', tentative: true });
expect(result.reason).toContain('preserve access');
expect(metricRecommendations([])).toEqual([]);
});
it('distinguishes a zero-duration sample from failed occupancy reporting', () => {
const [result] = metricRecommendations([map({ observed: 0, active: 0, idle: 0, playerHours: 0, capacityObserved: 0 })]);
expect(result.title).toBe('Let observations accumulate');
expect(result.reason).toContain('no elapsed observations');
});
it('leaves the lineup alone when measured evidence does not trigger a rule', () => {
expect(metricRecommendations([map({ active: 9, idle: 1, full: 0 })])[0].kind).toBe('watch');
});
});
+21 -1
View File
@@ -2,7 +2,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { createMetricsRefresh } from '../../src/lib/ui/metrics-refresh.js'; import { createMetricsRefresh } from '../../src/lib/ui/metrics-refresh.js';
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
const report = (days: number) => ({ days, summary: {}, points: [], maps: [], servers: [] }); const totals = { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 };
const report = (days: number) => ({ days, now: 1000, since: 0, summary: totals, points: [], maps: [], servers: [],
scope: { game: null, map: null, timeZone: 'UTC' }, scopeOptions: { games: [], maps: [] },
registeredServerCount: 0, firstRetainedSampleAt: null, latestSampleAt: null, scopeFirstSampleAt: null, scopeLatestSampleAt: null,
previous: { since: -1000, until: 0, summary: totals, firstSampleAt: null, lastSampleAt: null, partialHistory: true },
heatmap: { timeZone: 'UTC', cells: [] },
pooledPressure: { value: null, fullMapHours: 0, knownMapHours: 0, coveredMaps: 0, totalMaps: 0 }
});
function setup(fetcher: typeof fetch) { function setup(fetcher: typeof fetch) {
let query = 'days=7'; let query = 'days=7';
const onData = vi.fn(), onError = vi.fn(), onLoading = vi.fn(); const onData = vi.fn(), onError = vi.fn(), onLoading = vi.fn();
@@ -41,6 +48,19 @@ describe('metrics background refresh', () => {
expect(refresh.onData).toHaveBeenLastCalledWith(report(1)); expect(refresh.onData).toHaveBeenLastCalledWith(report(1));
}); });
it('preserves the report when an HTTP success lacks the expanded report contract', async () => {
const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ ...report(7), heatmap: null }))
.mockResolvedValueOnce(Response.json({ ...report(7), scope: { game: null, map: null, timeZone: 'Invalid/Zone' } }))
.mockResolvedValueOnce(Response.json(report(7)));
const refresh = setup(fetcher);
await refresh.refresh();
await refresh.refresh();
expect(refresh.onData).not.toHaveBeenCalled();
expect(refresh.onError).toHaveBeenLastCalledWith('The metrics response was incomplete.');
await refresh.refresh();
expect(refresh.onData).toHaveBeenCalledTimes(1);
});
it('suppresses errors from a cancelled request', async () => { it('suppresses errors from a cancelled request', async () => {
let reject!: (error: Error) => void; let reject!: (error: Error) => void;
const refresh = setup(vi.fn(() => new Promise<Response>((_, fail) => { reject = fail; }))); const refresh = setup(vi.fn(() => new Promise<Response>((_, fail) => { reject = fail; })));
+193
View File
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js'; import { newFixture, type Fixture } from '../helpers/fixture.js';
import { parseMetricsQuery } from '../../src/lib/metrics.js';
let fixture: Fixture; let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); }); beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
@@ -156,5 +157,197 @@ describe('historical metrics', () => {
expect(payload.days).toBe(['1', '7', '30'].includes(range) ? Number(range) : 7); 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); expect((await GET({ url: new URL('http://localhost/api/metrics') }).json()).days).toBe(7);
for (const search of ['days=1&game=t5&map=Moon&timeZone=Asia%2FKathmandu', 'days=7&map=Moon', 'game=t6&timeZone=Invalid']) {
const url = new URL(`http://localhost/metrics?${search}`);
const payload = await GET({ url }).json();
const { days, ...scope } = parseMetricsQuery(url.searchParams);
expect(payload).toEqual(await load({ url } as Parameters<typeof load>[0]));
expect(payload).toMatchObject({ days, scope });
}
});
it('normalizes malformed scopes consistently and requires a game for a map selection', () => {
expect(parseMetricsQuery(new URLSearchParams('days=365&map=Moon&timeZone=Invalid/Zone')))
.toEqual({ days: 7, game: null, map: null, timeZone: 'America/New_York' });
expect(parseMetricsQuery(new URLSearchParams({ game: 't5; DROP TABLE', map: 'Moon' }))).toMatchObject({ game: null, map: null });
expect(parseMetricsQuery(new URLSearchParams({ game: ' t5 ', map: 'Moon', timeZone: 'Asia/Kathmandu', days: '30' })))
.toEqual({ days: 30, game: 't5', map: 'Moon', timeZone: 'Asia/Kathmandu' });
for (const map of ['', ' ', 'a'.repeat(201), 'Moon\nTown']) {
expect(parseMetricsQuery(new URLSearchParams({ game: 't5', map })).map).toBeNull();
}
expect(parseMetricsQuery(new URLSearchParams({ game: 't5', map: 'Not recorded yet' })).map).toBe('Not recorded yet');
});
it('filters every derived value by exact game and map while preserving unfiltered collection boundaries', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 0, 3, 12);
const since = now - 86400_000;
const moon = { server: 'one', game: 't5', map: 'Moon', players: 4, capacity: 4 };
const other = { ...moon, server: 'other', game: 't6', players: 2 };
recordMetrics([moon, other], since - 30_000);
recordMetrics([other], since);
recordMetrics([moon, other, { ...moon, server: 'town', map: 'Town', players: 0 }], now - 60_000);
recordMetrics([other], now - 45_000);
recordMetrics([{ ...moon, players: 0 }, other], now - 30_000);
const query = parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Moon', timeZone: 'UTC' }));
const result = readMetrics(query, now);
expect(result.scope).toEqual({ game: 't5', map: 'Moon', timeZone: 'UTC' });
expect(result.summary.active * 3600).toBeCloseTo(15);
expect(result.summary.idle * 3600).toBeCloseTo(30);
expect(result.summary.playerHours * 3600).toBeCloseTo(60);
expect(result.previous.summary.active * 3600).toBeCloseTo(30);
expect(result.previous.summary.playerHours * 3600).toBeCloseTo(120);
expect(result.servers.map(server => server.server)).toEqual(['one']);
expect(result.maps.map(map => [map.game, map.map])).toEqual([['t5', 'Moon']]);
expect(result.scopeOptions).toEqual({ games: ['t5', 't6'], maps: [
{ game: 't5', map: 'Moon' }, { game: 't5', map: 'Town' }, { game: 't6', map: 'Moon' }
] });
expect(result.scopeFirstSampleAt).toBe(since - 30_000);
expect(result.scopeLatestSampleAt).toBe(now - 30_000);
expect(result.pooledPressure.value).toBeCloseTo(100 / 3);
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.heatmap.cells.reduce((sum, cell) => sum + cell[field], 0)).toBeCloseTo(result.summary[field]);
}
const gameOnly = readMetrics({ ...query, map: null }, now);
expect(gameOnly.maps.map(map => map.map).sort()).toEqual(['Moon', 'Town']);
const missing = readMetrics({ ...query, map: 'Unrecorded' }, now);
expect(missing).toMatchObject({ scopeFirstSampleAt: null, scopeLatestSampleAt: null,
summary: { observed: 0, unknown: 0 }, maps: [], servers: [], pooledPressure: { value: null },
previous: { firstSampleAt: null, lastSampleAt: null, partialHistory: true } });
expect(missing.scopeOptions).toEqual(result.scopeOptions);
});
it('separates scoped evidence timestamps from global collection timestamps', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 3, 12);
const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: 4 };
recordMetrics([{ ...sample, game: 't6' }], at - 60_000);
recordMetrics([sample], at - 30_000);
recordMetrics([{ ...sample, game: 't6' }], at);
const result = readMetrics(parseMetricsQuery(new URLSearchParams({ game: 't5' })), at + 30_000);
expect(result.firstRetainedSampleAt).toBe(at - 60_000);
expect(result.latestSampleAt).toBe(at);
expect(result.scopeFirstSampleAt).toBe(at - 30_000);
expect(result.scopeLatestSampleAt).toBe(at - 30_000);
});
it('compares adjacent equal windows and splits an observation at their shared boundary', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 0, 3, 12);
const since = now - 86400_000;
const sample = { server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 };
recordMetrics([sample], since - 15_000);
recordMetrics([{ ...sample, players: 0 }], since + 15_000);
const result = readMetrics(1, now);
expect(result.previous.since).toBe(now - 2 * 86400_000);
expect(result.previous.until).toBe(result.since);
expect(result.previous.summary.active * 3600).toBeCloseTo(15);
expect(result.summary.active * 3600).toBeCloseTo(15);
expect(result.summary.idle * 3600).toBeCloseTo(45);
expect(result.previous).toMatchObject({ firstSampleAt: since - 15_000, lastSampleAt: since - 15_000, partialHistory: true });
});
it('returns the available prior evidence when a thirty-day comparison exceeds retained history', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 2, 3, 12);
const day = 86400_000;
const sample = { server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 };
recordMetrics([sample], now - 31 * day + 30_000);
recordMetrics([{ ...sample, players: 0 }], now - 30 * day + 30_000);
recordMetrics([sample], now - 30_000);
const result = readMetrics(30, now);
expect(result.previous).toMatchObject({ since: now - 60 * day, until: now - 30 * day, partialHistory: true });
expect(result.previous.summary.active * 3600).toBeCloseTo(45);
expect(result.previous.firstSampleAt).toBe(now - 31 * day + 30_000);
expect(result.summary.active * 3600).toBeCloseTo(30);
expect(result.summary.idle * 3600).toBeCloseTo(45);
});
it('marks the retained start bound separately from gaps inside the previous window', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 0, 3, 12);
const priorStart = now - 2 * 86400_000;
const sample = { server: 'one', game: 't5', map: 'Moon', players: null, capacity: null };
recordMetrics([sample], priorStart - 15_000);
recordMetrics([sample], now - 30_000);
const result = readMetrics(1, now);
expect(result.previous.partialHistory).toBe(false);
expect(result.previous.summary.unknown * 3600).toBeCloseTo(30);
expect(result.previous.summary.observed).toBe(0);
expect(result.previous.firstSampleAt).toBe(priorStart - 15_000);
// Having the left edge says nothing about the uncaptured time after it.
expect(result.previous.summary.unknown).toBeLessThan(24);
});
it('pools pressure by known map-hours rather than server-hours or fleet elapsed time', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 3, 12);
const full = { server: 'one', game: 't5', map: 'Moon', players: 4, capacity: 4 };
recordMetrics([full, { ...full, server: 'two' }, { ...full, server: 'three', map: 'Town', players: 0 },
{ ...full, server: 'four', map: 'Farm', players: 2, capacity: null }], at);
const result = readMetrics(1, at + 30_000);
expect(result.pooledPressure).toMatchObject({ value: 50, coveredMaps: 2, totalMaps: 3 });
expect(result.pooledPressure.fullMapHours * 3600).toBeCloseTo(30);
expect(result.pooledPressure.knownMapHours * 3600).toBeCloseTo(60);
expect(result.summary.observed * 3600).toBeCloseTo(120);
const empty = readMetrics(parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Town' })), at + 30_000);
expect(empty.pooledPressure.value).toBe(0);
const missing = readMetrics(parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Farm' })), at + 30_000);
expect(missing.pooledPressure.value).toBeNull();
});
it.each([
{ zone: 'America/New_York', at: '2026-03-08T06:59:45Z', first: [6, 1], second: [6, 3] },
{ zone: 'Asia/Kolkata', at: '2026-01-01T18:29:45Z', first: [3, 23], second: [4, 0] },
{ zone: 'Asia/Kathmandu', at: '2026-01-01T18:14:45Z', first: [3, 23], second: [4, 0] },
{ zone: 'Australia/Lord_Howe', at: '2026-10-03T15:29:45Z', first: [6, 1], second: [6, 2] }
])('splits heatmap time across local boundaries in $zone', async ({ zone, at: timestamp, first, second }) => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const at = Date.parse(timestamp);
recordMetrics([{ server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 }], at);
const result = readMetrics(parseMetricsQuery(new URLSearchParams({ days: '1', timeZone: zone })), at + 30_000);
expect(result.heatmap.timeZone).toBe(zone);
expect(result.heatmap.cells).toHaveLength(168);
for (const [weekday, hour] of [first, second]) {
const cell = result.heatmap.cells.find(cell => cell.weekday === weekday && cell.hour === hour)!;
expect(cell.observed * 3600).toBeCloseTo(15);
expect(cell.playerHours * 3600).toBeCloseTo(30);
expect(cell.distinctDates).toBe(1);
}
expect(result.heatmap.cells.reduce((sum, cell) => sum + cell.observed, 0)).toBeCloseTo(result.summary.observed);
});
it('weights repeated fall-back hours and counts their local date once', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const at = Date.parse('2026-11-01T05:30:00Z');
const sample = { server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 };
recordMetrics([sample], at);
recordMetrics([{ ...sample, players: 0 }], at + 3600_000);
const result = readMetrics(parseMetricsQuery(new URLSearchParams({ days: '1', timeZone: 'America/New_York' })), at + 3630_000);
const cell = result.heatmap.cells.find(cell => cell.weekday === 6 && cell.hour === 1)!;
expect(cell.observed * 3600).toBeCloseTo(75);
expect(cell.active * 3600).toBeCloseTo(45);
expect(cell.idle * 3600).toBeCloseTo(30);
expect(cell.distinctDates).toBe(1);
expect(result.heatmap.cells.filter(cell => cell.observed > 0)).toHaveLength(1);
});
it('counts unknown heatmap evidence and distinguishes retained names from current registrations', 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, 3, 12);
const sample = { server: 'reused', game: 't5', map: 'Moon', players: null, capacity: null };
recordMetrics([sample, { ...sample, server: 'removed' }, { ...sample, server: 'tombstone' }], at);
// A newly registered name can link to the current server without asserting
// that its earlier historical observations belong to this registration.
db().prepare('INSERT INTO servers (name, komodo_name, created_at) VALUES (?, ?, ?)').run('reused', 'reused', new Date(at + 15_000).toISOString());
db().prepare('INSERT INTO servers (name, komodo_name, destroyed_at) VALUES (?, ?, ?)').run('tombstone', 'tombstone', new Date(at).toISOString());
const result = readMetrics(1, at + 30_000);
expect(result.servers.find(server => server.server === 'reused')!.currentRegistered).toBe(true);
expect(result.servers.filter(server => server.server !== 'reused').every(server => !server.currentRegistered)).toBe(true);
expect(result.heatmap.cells.reduce((sum, cell) => sum + cell.unknown, 0) * 3600).toBeCloseTo(90);
expect(result.heatmap.cells.reduce((sum, cell) => sum + cell.observed, 0)).toBe(0);
expect(result.heatmap.cells.find(cell => cell.unknown > 0)!.distinctDates).toBe(1);
}); });
}); });