Improve metrics clarity, exploration, and refresh reliability
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
export type MetricsDays = 1 | 7 | 30;
|
||||
|
||||
export function parseMetricsDays(value: string | null | undefined): MetricsDays {
|
||||
const days = Number(value ?? 7);
|
||||
return days === 1 || days === 7 || days === 30 ? days : 7;
|
||||
}
|
||||
|
||||
/** All durations are hours. Observed is known server time, not collection coverage. */
|
||||
export interface Utilization {
|
||||
observed: number;
|
||||
active: number;
|
||||
idle: number;
|
||||
unknown: number;
|
||||
playerHours: number;
|
||||
}
|
||||
|
||||
export interface ServerMetrics extends Utilization {
|
||||
server: string;
|
||||
}
|
||||
|
||||
export interface MapMetrics extends Utilization {
|
||||
game: string;
|
||||
map: string;
|
||||
/** Map wall time, not summed server time. */
|
||||
full: number;
|
||||
capacityObserved: number;
|
||||
}
|
||||
|
||||
export interface MetricPoint extends Utilization {
|
||||
/** UTC bucket boundary, including when the selected interval starts later. */
|
||||
at: number;
|
||||
startAt: number;
|
||||
endAt: number;
|
||||
partial: boolean;
|
||||
}
|
||||
|
||||
export interface MetricsData {
|
||||
days: MetricsDays;
|
||||
since: number;
|
||||
now: number;
|
||||
/** Retained sample timestamps describe collection, not gameplay freshness. */
|
||||
firstRetainedSampleAt: number | null;
|
||||
latestSampleAt: number | null;
|
||||
registeredServerCount: number;
|
||||
summary: Utilization;
|
||||
servers: ServerMetrics[];
|
||||
maps: MapMetrics[];
|
||||
points: MetricPoint[];
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { ONLINE_MAX_MS } from '../../server-freshness.js';
|
||||
import type { MapMetrics, MetricPoint, MetricsData, MetricsDays, ServerMetrics, Utilization } from '../../metrics.js';
|
||||
|
||||
export type { MapMetrics, Utilization } from '../../metrics.js';
|
||||
|
||||
export interface MetricSample {
|
||||
server: string;
|
||||
@@ -18,61 +21,105 @@ export function recordMetrics(samples: MetricSample[], at = Date.now()): void {
|
||||
})();
|
||||
}
|
||||
|
||||
export interface Utilization {
|
||||
observed: number;
|
||||
active: number;
|
||||
idle: number;
|
||||
unknown: number;
|
||||
playerHours: number;
|
||||
}
|
||||
export interface MapMetrics extends Utilization {
|
||||
game: string;
|
||||
map: string;
|
||||
full: number;
|
||||
capacityObserved: number;
|
||||
function emptyUtilization(): Utilization {
|
||||
return { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 };
|
||||
}
|
||||
|
||||
/** Treat each observation as valid until the next poll, capped at the freshness limit.
|
||||
* Longer collection gaps are excluded, never interpreted as empty servers. */
|
||||
export function readMetrics(days: number, now = Date.now()) {
|
||||
function addObservation(total: Utilization, players: number | null, hours: number): void {
|
||||
if (players === null) total.unknown += hours;
|
||||
else {
|
||||
total.observed += hours;
|
||||
if (players > 0) total.active += hours;
|
||||
else if (players === 0) total.idle += hours;
|
||||
total.playerHours += players * hours;
|
||||
}
|
||||
}
|
||||
|
||||
/** Each collection is a complete fleet snapshot. Hold it until the next collection,
|
||||
* capped at the freshness limit; longer gaps contribute no observed or unknown time.
|
||||
* Read the indexed history once and share the intervals across every aggregation. */
|
||||
export function readMetrics(days: MetricsDays, now = Date.now()): MetricsData {
|
||||
const since = now - days * 86400_000;
|
||||
const bucketMs = days === 1 ? 3600_000 : 86400_000;
|
||||
const handle = db();
|
||||
const cte = `WITH times AS (
|
||||
SELECT at, LEAD(at, 1, @now) OVER (ORDER BY at) AS next_at
|
||||
FROM (SELECT DISTINCT at FROM metric_samples WHERE at >= @since - @fresh AND at <= @now)
|
||||
), samples AS (
|
||||
SELECT s.*, MAX(0, MIN(t.next_at, s.at + @fresh, @now) - MAX(s.at, @since)) / 3600000.0 AS hours
|
||||
FROM metric_samples s JOIN times t ON t.at = s.at
|
||||
)`;
|
||||
const params = { now, since, fresh: ONLINE_MAX_MS };
|
||||
const totals = `COALESCE(SUM(CASE WHEN players IS NOT NULL THEN hours ELSE 0 END), 0) AS observed,
|
||||
COALESCE(SUM(CASE WHEN players > 0 THEN hours ELSE 0 END), 0) AS active,
|
||||
COALESCE(SUM(CASE WHEN players = 0 THEN hours ELSE 0 END), 0) AS idle,
|
||||
COALESCE(SUM(CASE WHEN players IS NULL THEN hours ELSE 0 END), 0) AS unknown,
|
||||
COALESCE(SUM(players * hours), 0) AS playerHours`;
|
||||
const summary = handle.prepare(`${cte} SELECT ${totals} FROM samples`).get(params) as Utilization;
|
||||
const servers = handle.prepare(`${cte} SELECT server, ${totals} FROM samples GROUP BY server ORDER BY active DESC, server`).all(params) as (Utilization & { server: string })[];
|
||||
const maps = handle.prepare(`${cte}, pressure AS (
|
||||
SELECT game, map, at, MAX(hours) AS hours,
|
||||
MIN(CASE WHEN players IS NOT NULL AND capacity > 0 THEN 1 ELSE 0 END) AS known,
|
||||
MIN(CASE WHEN players >= capacity AND capacity > 0 THEN 1 ELSE 0 END) AS full
|
||||
FROM samples GROUP BY game, map, at
|
||||
), map_pressure AS (
|
||||
SELECT game, map, SUM(CASE WHEN known = 1 THEN hours ELSE 0 END) AS capacityObserved,
|
||||
SUM(CASE WHEN known = 1 AND full = 1 THEN hours ELSE 0 END) AS full
|
||||
FROM pressure GROUP BY game, map
|
||||
) SELECT s.game, s.map, ${totals}, p.full, p.capacityObserved
|
||||
FROM samples s JOIN map_pressure p ON p.game = s.game AND p.map = s.map
|
||||
GROUP BY s.game, s.map ORDER BY playerHours DESC, s.map`).all(params) as MapMetrics[];
|
||||
// Split intervals at chart boundaries so a midnight poll cannot shift time into the wrong day.
|
||||
const points: (Utilization & { at: number })[] = [];
|
||||
const metadata = handle.prepare(`SELECT
|
||||
(SELECT MIN(at) FROM metric_samples WHERE at <= @now) AS firstRetainedSampleAt,
|
||||
(SELECT MAX(at) FROM metric_samples WHERE at <= @now) AS latestSampleAt,
|
||||
(SELECT COUNT(*) FROM servers WHERE destroyed_at IS NULL) AS registeredServerCount`).get({ now }) as
|
||||
Pick<MetricsData, 'firstRetainedSampleAt' | 'latestSampleAt' | 'registeredServerCount'>;
|
||||
const summary = emptyUtilization();
|
||||
const serverTotals = new Map<string, ServerMetrics>();
|
||||
const mapTotals = new Map<string, Map<string, MapMetrics>>();
|
||||
const points: MetricPoint[] = [];
|
||||
for (let at = Math.floor(since / bucketMs) * bucketMs; at < now; at += bucketMs) {
|
||||
const end = Math.min(at + bucketMs, now);
|
||||
const start = Math.max(at, since);
|
||||
const point = handle.prepare(`${cte} SELECT ${totals} FROM samples`)
|
||||
.get({ ...params, since: start, now: end }) as Utilization;
|
||||
points.push({ at, ...point });
|
||||
points.push({ at, startAt: start, endAt: end, partial: start !== at || end !== at + bucketMs, ...emptyUtilization() });
|
||||
}
|
||||
return { days, since, now, summary, servers, maps, points };
|
||||
|
||||
type RecordedSample = MetricSample & { at: number };
|
||||
let batch: RecordedSample[] = [];
|
||||
function aggregateBatch(nextAt: number): void {
|
||||
if (!batch.length) return;
|
||||
const start = Math.max(batch[0].at, since);
|
||||
const end = Math.min(nextAt, batch[0].at + ONLINE_MAX_MS, now);
|
||||
const hours = Math.max(0, end - start) / 3600_000;
|
||||
const pressure = new Map<MapMetrics, { known: boolean; full: boolean }>();
|
||||
// Split once for the entire batch; observations crossing UTC hour/day
|
||||
// boundaries contribute to both buckets without shifting their duration.
|
||||
const slices: { point: MetricPoint; hours: number }[] = [];
|
||||
for (let cursor = start; cursor < end;) {
|
||||
const index = Math.floor((cursor - points[0].at) / bucketMs);
|
||||
const point = points[index];
|
||||
const stop = Math.min(end, point.endAt);
|
||||
slices.push({ point, hours: (stop - cursor) / 3600_000 });
|
||||
cursor = stop;
|
||||
}
|
||||
for (const sample of batch) {
|
||||
let server = serverTotals.get(sample.server);
|
||||
if (!server) {
|
||||
server = { server: sample.server, ...emptyUtilization() };
|
||||
serverTotals.set(sample.server, server);
|
||||
}
|
||||
let game = mapTotals.get(sample.game);
|
||||
if (!game) { game = new Map(); mapTotals.set(sample.game, game); }
|
||||
let map = game.get(sample.map);
|
||||
if (!map) {
|
||||
map = { game: sample.game, map: sample.map, full: 0, capacityObserved: 0, ...emptyUtilization() };
|
||||
game.set(sample.map, map);
|
||||
}
|
||||
addObservation(summary, sample.players, hours);
|
||||
addObservation(server, sample.players, hours);
|
||||
addObservation(map, sample.players, hours);
|
||||
for (const slice of slices) addObservation(slice.point, sample.players, slice.hours);
|
||||
const known = sample.players !== null && sample.capacity !== null && sample.capacity > 0;
|
||||
const full = known && sample.players! >= sample.capacity!;
|
||||
const previous = pressure.get(map);
|
||||
pressure.set(map, { known: known && (previous?.known ?? true), full: full && (previous?.full ?? true) });
|
||||
}
|
||||
for (const [map, state] of pressure) {
|
||||
if (state.known) {
|
||||
map.capacityObserved += hours;
|
||||
if (state.full) map.full += hours;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type SampleRow = [at: number, server: string, game: string, map: string, players: number | null, capacity: number | null];
|
||||
const samples = handle.prepare(`SELECT at, server, game, map, players, capacity FROM metric_samples
|
||||
WHERE at >= ? AND at <= ? ORDER BY at`).raw().iterate(since - ONLINE_MAX_MS, now) as IterableIterator<SampleRow>;
|
||||
for (const [at, server, game, map, players, capacity] of samples) {
|
||||
if (batch.length && at !== batch[0].at) {
|
||||
aggregateBatch(at);
|
||||
batch = [];
|
||||
}
|
||||
batch.push({ at, server, game, map, players, capacity });
|
||||
}
|
||||
aggregateBatch(now);
|
||||
|
||||
const compareText = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
|
||||
const servers = [...serverTotals.values()].sort((a, b) => b.active - a.active || compareText(a.server, b.server));
|
||||
const maps = [...mapTotals.values()].flatMap(game => [...game.values()])
|
||||
.sort((a, b) => b.playerHours - a.playerHours || compareText(a.map, b.map) || compareText(a.game, b.game));
|
||||
return { days, since, now, ...metadata, summary, servers, maps, points };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import type { MetricPoint, MetricsDays } from '$lib/metrics.js';
|
||||
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
|
||||
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
|
||||
let { points, days }: { points: MetricPoint[]; days: MetricsDays } = $props();
|
||||
let selectedAt = $state<number | null>(null);
|
||||
const selectedIndex = $derived.by(() => {
|
||||
const existing = points.findIndex(point => point.at === selectedAt);
|
||||
if (existing >= 0) return existing;
|
||||
for (let i = points.length - 1; i >= 0; i--) if (!points[i].partial) return i;
|
||||
return Math.max(0, points.length - 1);
|
||||
});
|
||||
const selected = $derived(points[selectedIndex]);
|
||||
const selectedValue = $derived(selected ? String(selected.at) : '');
|
||||
const ceiling = $derived.by(() => {
|
||||
const max = Math.max(0, ...points.map(point => point.observed + point.unknown));
|
||||
if (!max) return 1;
|
||||
const magnitude = 10 ** Math.floor(Math.log10(max));
|
||||
return Math.ceil(max / (magnitude / 2)) * (magnitude / 2);
|
||||
});
|
||||
const tick = new Intl.NumberFormat('en-US', { maximumSignificantDigits: 3 });
|
||||
const ticks = $derived([1, .75, .5, .25, 0].map(fraction => ({ fraction, value: tick.format(ceiling * fraction) })));
|
||||
const periodOptions = $derived(points.map(point => ({ value: String(point.at), label: `${$timeFormat.period(point.startAt)}${point.partial ? ' · partial' : ''}` })));
|
||||
const middle = $derived(points[Math.floor(points.length / 2)]);
|
||||
const captured = $derived(selected ? selected.observed + selected.unknown : 0);
|
||||
|
||||
function move(direction: number) {
|
||||
const next = points[Math.max(0, Math.min(points.length - 1, selectedIndex + direction))];
|
||||
if (next) selectedAt = next.at;
|
||||
}
|
||||
function chartKey(event: KeyboardEvent, index: number) {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? points.length - 1 : Math.max(0, Math.min(points.length - 1, index + (event.key === 'ArrowLeft' ? -1 : 1)));
|
||||
selectedAt = points[next].at;
|
||||
(event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLButtonElement>('button')[next]?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel" aria-labelledby="metrics-trend-title">
|
||||
<div class="heading"><h2 id="metrics-trend-title">Server time over time</h2><p>Server-hours per {days === 1 ? 'hour' : '24-hour period'}. Partial periods contain less elapsed time.</p></div>
|
||||
<div class="legend"><span><i class="occupied"></i>With players</span><span><i class="idle"></i>Idle</span><span><i class="unknown"></i>Unknown</span><span class="partial-key">* Partial period</span></div>
|
||||
<div class="axis-title">Server-hours</div>
|
||||
<div class="chart-grid">
|
||||
<div class="y-axis" aria-hidden="true">{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}>{item.value}</span>{/each}</div>
|
||||
<div class="plot" role="group" aria-label="Server time by period. Select a bar or use the period selector below.">
|
||||
<div class="grid-lines" aria-hidden="true">{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}></span>{/each}</div>
|
||||
{#each points as point, index (point.at)}
|
||||
<button class="bar" class:selected={point.at === selected?.at} class:partial={point.partial} type="button" tabindex={index === selectedIndex ? 0 : -1}
|
||||
aria-pressed={point.at === selected?.at}
|
||||
aria-label={`${$timeFormat.period(point.startAt)}${point.partial ? ', partial period' : ''}: ${metricDuration(point.active)} with players, ${metricDuration(point.idle)} idle, ${metricDuration(point.unknown)} unknown`}
|
||||
title={`${$timeFormat.period(point.startAt)} – ${$timeFormat.period(point.endAt)}${point.partial ? ' · partial period' : ''}`}
|
||||
onclick={() => { selectedAt = point.at; }} onkeydown={event => chartKey(event, index)}>
|
||||
{#if point.partial}<span class="partial-marker" aria-hidden="true">*</span>{/if}
|
||||
<span class="segment unknown" style:height={`${point.unknown / ceiling * 100}%`}></span>
|
||||
<span class="segment idle" style:height={`${point.idle / ceiling * 100}%`}></span>
|
||||
<span class="segment occupied" style:height={`${point.active / ceiling * 100}%`}></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="x-axis" aria-hidden="true"><span>{points[0] ? $timeFormat.period(points[0].startAt) : ''}</span>{#if middle}<span class="middle">{$timeFormat.period(middle.startAt)}</span>{/if}<span>{points.length ? $timeFormat.period(points[points.length - 1].endAt) : ''}</span></div>
|
||||
</div>
|
||||
{#if selected}
|
||||
<div class="period-controls">
|
||||
<div class="period-select"><Field label={`Inspect a period · ${$timeZone.replaceAll('_', ' ')}`}><Select value={selectedValue} options={periodOptions} onchange={event => { selectedAt = Number((event.currentTarget as HTMLSelectElement).value); }} /></Field></div>
|
||||
<div class="step-buttons"><Button size="sm" disabled={selectedIndex === 0} onclick={() => move(-1)}>← Previous</Button><Button size="sm" disabled={selectedIndex === points.length - 1} onclick={() => move(1)}>Next →</Button></div>
|
||||
</div>
|
||||
<div class="readout" aria-label="Selected period details">
|
||||
<p class="interval">{$timeFormat.period(selected.startAt)} – {$timeFormat.period(selected.endAt)} {#if selected.partial}<span class="partial-label">Partial period</span>{/if}</p>
|
||||
{#if captured === 0}<p class="empty">No captured time in this period. Missing observations do not mean the fleet was idle.</p>
|
||||
{:else}<dl>
|
||||
<div><dt>With players</dt><dd>{metricDuration(selected.active)}<small>{metricPercent(selected.active, selected.observed)} of known time</small></dd></div>
|
||||
<div><dt>Idle</dt><dd>{metricDuration(selected.idle)}</dd></div>
|
||||
<div><dt>Unknown</dt><dd>{metricDuration(selected.unknown)}</dd></div>
|
||||
<div><dt>Player-hours</dt><dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd></div>
|
||||
</dl>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<details class="time-data"><summary>View exact time data</summary>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Time data" tabindex="0"><table>
|
||||
<caption>Periods in {$timeZone.replaceAll('_', ' ')}. Durations are estimated from captured observations.</caption>
|
||||
<thead><tr><th scope="col">Period</th><th scope="col">With players</th><th scope="col">Idle</th><th scope="col">Unknown</th><th scope="col">Player-hours</th></tr></thead>
|
||||
<tbody>{#each points as point (point.at)}<tr><th scope="row">{$timeFormat.period(point.startAt)}<small>to {$timeFormat.period(point.endAt)}{point.partial ? ' · Partial' : ''}</small></th><td>{metricDuration(point.active)}</td><td>{metricDuration(point.idle)}</td><td>{metricDuration(point.unknown)}</td><td>{point.observed ? metricNumber(point.playerHours) : '—'}</td></tr>{/each}</tbody>
|
||||
</table></div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); min-width: 0; }
|
||||
h2 { margin: 0; font-size: var(--text-lg); }
|
||||
.heading p { margin: var(--space-2) 0 var(--space-4); color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.legend { display: flex; gap: var(--space-4); flex-wrap: wrap; align-items: center; font-size: var(--text-xs); }
|
||||
.legend span { display: flex; align-items: center; gap: 6px; }
|
||||
.legend i { width: 10px; height: 10px; border-radius: 2px; }
|
||||
.occupied { background: var(--chart-active); }
|
||||
.idle { background: var(--chart-idle); }
|
||||
.unknown { background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 3px, var(--chart-unknown-stripe) 3px 5px); }
|
||||
.partial-key { color: var(--text-secondary); }
|
||||
.axis-title { margin-top: var(--space-5); font-size: 11px; color: var(--text-tertiary); }
|
||||
.chart-grid { display: grid; grid-template-columns: 44px minmax(0, 1fr); column-gap: 8px; margin-top: 12px; }
|
||||
.y-axis { position: relative; text-align: right; font-size: 11px; color: var(--text-tertiary); height: 210px; }
|
||||
.y-axis span { position: absolute; right: 0; transform: translateY(-50%); }
|
||||
.plot { display: flex; position: relative; height: 210px; gap: 4px; isolation: isolate; border-bottom: 1px solid var(--border-strong); }
|
||||
.grid-lines { position: absolute; inset: 0; pointer-events: none; z-index: -1; }
|
||||
.grid-lines span { position: absolute; left: 0; right: 0; border-top: 1px solid var(--border-subtle); }
|
||||
.bar { appearance: none; position: relative; flex: 1; min-width: 0; border: 0; margin: 0; padding: 0; background: transparent; display: flex; flex-direction: column; justify-content: flex-end; cursor: pointer; border-radius: 2px 2px 0 0; }
|
||||
.bar.selected { outline: 1px solid var(--text-secondary); outline-offset: 2px; }
|
||||
.bar:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 2px; }
|
||||
.bar:hover { background: color-mix(in oklab, var(--accent-primary) 8%, transparent); }
|
||||
.segment { display: block; width: 100%; flex-shrink: 0; pointer-events: none; }
|
||||
.bar.partial { border-inline: 1px dashed var(--border-strong); }
|
||||
.partial-marker { position: absolute; top: -17px; left: 50%; transform: translateX(-50%); font-size: 15px; color: var(--text-secondary); }
|
||||
.x-axis { grid-column: 2; display: flex; justify-content: space-between; gap: 16px; color: var(--text-tertiary); font-size: 10px; margin-top: 10px; }
|
||||
.x-axis span { max-width: 25ch; }
|
||||
.x-axis span:last-child { text-align: right; }
|
||||
.x-axis .middle { text-align: center; }
|
||||
.period-controls { display: flex; align-items: flex-end; gap: var(--space-3); flex-wrap: wrap; margin-top: var(--space-5); }
|
||||
.period-select { flex: 1 1 250px; max-width: 450px; }
|
||||
.step-buttons { display: flex; gap: var(--space-2); padding-bottom: 3px; }
|
||||
.readout { margin-top: var(--space-4); padding: var(--space-4); border-radius: var(--radius-md); background: var(--surface-sunken); border: 1px solid var(--border-subtle); }
|
||||
.interval { color: var(--text-secondary); font-size: var(--text-xs); margin: 0; line-height: 1.6; }
|
||||
.partial-label { display: inline-block; margin-left: 4px; padding: 1px 6px; border: 1px dashed var(--border-strong); border-radius: 4px; }
|
||||
dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); margin: var(--space-3) 0 0; }
|
||||
dt { color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
dd { margin: var(--space-1) 0 0; font-variant-numeric: tabular-nums; font-size: var(--text-md); }
|
||||
small { display: block; color: var(--text-tertiary); font-size: 11px; font-weight: normal; line-height: 1.5; }
|
||||
.empty { margin: var(--space-3) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
details { margin-top: var(--space-4); font-size: var(--text-sm); }
|
||||
summary { cursor: pointer; color: var(--text-secondary); padding: 4px 0; }
|
||||
.table-wrap { max-width: 100%; overflow-x: auto; margin-top: var(--space-3); }
|
||||
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: var(--text-xs); }
|
||||
caption { text-align: left; color: var(--text-tertiary); margin-bottom: 8px; }
|
||||
th, td { padding: 12px; border-bottom: 1px solid var(--border-subtle); text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
thead th { color: var(--text-secondary); }
|
||||
th:first-child { text-align: left; }
|
||||
tbody th { font-weight: var(--weight-regular); }
|
||||
@media (max-width: 640px) { .panel { padding: var(--space-4); } .legend { gap: 10px; } .chart-grid { grid-template-columns: 32px minmax(0, 1fr); gap: 6px; } .plot { gap: 3px; } .x-axis .middle { display: none; } .x-axis { font-size: 11px; } dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } .readout { padding: var(--space-3); } }
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import type { MapMetrics, ServerMetrics } from '$lib/metrics.js';
|
||||
import { compareMetricValues, metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||
import Input from '$ui/components/primitives/Input.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
|
||||
let { maps, servers }: { maps: MapMetrics[]; servers: ServerMetrics[] } = $props();
|
||||
const mapColumns = [
|
||||
{ key: 'map', label: 'Map' }, { key: 'playerHours', label: 'Player-hours' },
|
||||
{ key: 'active', label: 'With players' }, { key: 'full', label: 'All lobbies full' },
|
||||
{ key: 'capacityObserved', label: 'Known capacity' }
|
||||
] as const;
|
||||
const serverColumns = [
|
||||
{ key: 'server', label: 'Server' }, { key: 'utilization', label: 'Time with players' },
|
||||
{ key: 'idle', label: 'Idle' }, { key: 'observed', label: 'Known server time' }, { key: 'unknown', label: 'Unknown' }
|
||||
] as const;
|
||||
type MapSort = typeof mapColumns[number]['key'];
|
||||
type ServerSort = typeof serverColumns[number]['key'];
|
||||
type Direction = 'asc' | 'desc';
|
||||
// Shallow navigation changes page.state, not page.url. Store the working
|
||||
// search string there so filters react immediately and survive Back/Forward.
|
||||
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
|
||||
const params = $derived(new URLSearchParams(contextSearch));
|
||||
const mapQuery = $derived((params.get('mapQ') ?? '').slice(0, 100));
|
||||
const serverQuery = $derived((params.get('serverQ') ?? '').slice(0, 100));
|
||||
const mapSort = $derived(mapColumns.find(column => column.key === params.get('mapSort'))?.key ?? 'playerHours');
|
||||
const serverSort = $derived(serverColumns.find(column => column.key === params.get('serverSort'))?.key ?? 'utilization');
|
||||
const mapDirection = $derived<Direction>(params.get('mapDirection') === 'asc' ? 'asc' : 'desc');
|
||||
const serverDirection = $derived<Direction>(params.get('serverDirection') === 'asc' ? 'asc' : 'desc');
|
||||
const maxPlayerHours = $derived(Math.max(0, ...maps.map(map => map.playerHours)));
|
||||
const mapRows = $derived(maps.filter(map => `${map.game} ${gameName(map.game)} ${map.map}`.toLowerCase().includes(mapQuery.trim().toLowerCase())).sort((a, b) => {
|
||||
const result = mapSort === 'map' ? a.map.localeCompare(b.map) * (mapDirection === 'asc' ? 1 : -1)
|
||||
: compareMetricValues(mapValue(a, mapSort), mapValue(b, mapSort), mapDirection);
|
||||
return result || a.map.localeCompare(b.map) || a.game.localeCompare(b.game);
|
||||
}));
|
||||
const serverRows = $derived(servers.filter(server => `${server.server} ${serverTitle(server.server)}`.toLowerCase().includes(serverQuery.trim().toLowerCase())).sort((a, b) => {
|
||||
const result = serverSort === 'server' ? serverTitle(a.server).localeCompare(serverTitle(b.server)) * (serverDirection === 'asc' ? 1 : -1)
|
||||
: compareMetricValues(serverValue(a, serverSort), serverValue(b, serverSort), serverDirection);
|
||||
return result || b.observed - a.observed || a.server.localeCompare(b.server);
|
||||
}));
|
||||
function gameName(game: string) { return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase(); }
|
||||
function mapValue(map: MapMetrics, key: Exclude<MapSort, 'map'>): number | null {
|
||||
return key === 'full' && !map.capacityObserved || key === 'playerHours' && !map.observed ? null : map[key];
|
||||
}
|
||||
function serverValue(server: ServerMetrics, key: Exclude<ServerSort, 'server'>): number | null {
|
||||
if (key === 'utilization' || key === 'idle') return server.observed > 0 ? server[key === 'utilization' ? 'active' : 'idle'] / server.observed : null;
|
||||
return server[key];
|
||||
}
|
||||
function updateContext(changes: Record<string, string>) {
|
||||
const url = new URL(page.url);
|
||||
url.search = contextSearch;
|
||||
for (const [key, value] of Object.entries(changes)) { if (value) url.searchParams.set(key, value); else url.searchParams.delete(key); }
|
||||
replaceState(url, { ...page.state, metricsTableSearch: url.search });
|
||||
}
|
||||
function sortMap(key: MapSort) { updateContext({ mapSort: key, mapDirection: key === mapSort ? mapDirection === 'asc' ? 'desc' : 'asc' : key === 'map' ? 'asc' : 'desc' }); }
|
||||
function sortServer(key: ServerSort) { updateContext({ serverSort: key, serverDirection: key === serverSort ? serverDirection === 'asc' ? 'desc' : 'asc' : key === 'server' ? 'asc' : 'desc' }); }
|
||||
</script>
|
||||
|
||||
<section class="panel" aria-labelledby="map-metrics-title">
|
||||
<div class="heading"><div><h2 id="map-metrics-title">Map play time & capacity</h2><p>Compare play time and how often every tracked lobby was full.</p></div>
|
||||
<div class="search"><Field label="Find a map" hint="Search this table only."><Input type="search" value={mapQuery} placeholder="Map or game…" oninput={event => updateContext({ mapQ: (event.currentTarget as HTMLInputElement).value })} /></Field></div>
|
||||
</div>
|
||||
<p class="definition">“All lobbies full” uses map time when every tracked lobby has known occupancy and capacity. It does not measure failed joins or unmet demand.</p>
|
||||
{#if mapRows.length}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Map play time and capacity table" tabindex="0"><table>
|
||||
<thead><tr>{#each mapColumns as column}<th scope="col" aria-sort={mapSort === column.key ? mapDirection === 'asc' ? 'ascending' : 'descending' : undefined}><button type="button" onclick={() => sortMap(column.key)}>{column.label}<span aria-hidden="true">{mapSort === column.key ? mapDirection === 'asc' ? '↑' : '↓' : '↕'}</span></button></th>{/each}</tr></thead>
|
||||
<tbody>{#each mapRows as map (JSON.stringify([map.game, map.map]))}<tr>
|
||||
<th scope="row">{map.map}<small>{gameName(map.game)} · {map.game.toUpperCase()}</small></th>
|
||||
<td class="player-hours"><span>{map.observed ? metricNumber(map.playerHours) : '—'}</span><div class="comparison" aria-hidden="true"><i style:width={`${maxPlayerHours ? map.playerHours / maxPlayerHours * 100 : 0}%`}></i></div></td>
|
||||
<td>{metricDuration(map.active)}<small>server time</small></td>
|
||||
<td>{#if map.capacityObserved > 0}{metricDuration(map.full)}<small>{metricPercent(map.full, map.capacityObserved)} during {metricDuration(map.capacityObserved)} known</small>{:else}<span class="unavailable">Unknown</span><small>No known capacity time</small>{/if}</td>
|
||||
<td>{metricDuration(map.capacityObserved)}<small>map time</small></td>
|
||||
</tr>{/each}</tbody>
|
||||
</table></div>
|
||||
{:else}<EmptyState title={maps.length ? 'No maps match this search' : 'No map observations'} description={maps.length ? 'Try a recorded map name or game.' : 'Map rows appear when server observations include a map.'}>{#snippet actions()}{#if mapQuery}<Button size="sm" onclick={() => updateContext({ mapQ: '' })}>Clear map search</Button>{/if}{/snippet}</EmptyState>{/if}
|
||||
<div class="footer"><span>{mapRows.length} of {maps.length} maps</span><span>Missing player limits leave capacity unknown, even when occupancy is available.</span></div>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="server-metrics-title">
|
||||
<div class="heading"><div><h2 id="server-metrics-title">Server utilization</h2><p>Time with players as a share of each server’s known time.</p></div>
|
||||
<div class="search"><Field label="Find a server" hint="Search this table only."><Input type="search" value={serverQuery} placeholder="Server name or map…" oninput={event => updateContext({ serverQ: (event.currentTarget as HTMLInputElement).value })} /></Field></div>
|
||||
</div>
|
||||
{#if serverRows.length}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Server utilization table" tabindex="0"><table>
|
||||
<thead><tr>{#each serverColumns as column}<th scope="col" aria-sort={serverSort === column.key ? serverDirection === 'asc' ? 'ascending' : 'descending' : undefined}><button type="button" onclick={() => sortServer(column.key)}>{column.label}<span aria-hidden="true">{serverSort === column.key ? serverDirection === 'asc' ? '↑' : '↓' : '↕'}</span></button></th>{/each}</tr></thead>
|
||||
<tbody>{#each serverRows as server (server.server)}<tr>
|
||||
<th scope="row"><ServerIdentity name={server.server} /><small class="server-id">{server.server}</small></th>
|
||||
<td>{metricPercent(server.active, server.observed)}<small>{metricDuration(server.active)} of {metricDuration(server.observed)} known</small></td>
|
||||
<td>{metricPercent(server.idle, server.observed)}<small>{metricDuration(server.idle)} idle</small></td>
|
||||
<td>{metricDuration(server.observed)}</td><td>{metricDuration(server.unknown)}</td>
|
||||
</tr>{/each}</tbody>
|
||||
</table></div>
|
||||
{:else}<EmptyState title="No servers match this search" description="Try a friendly map name or technical server ID.">{#snippet actions()}<Button size="sm" onclick={() => updateContext({ serverQ: '' })}>Clear server search</Button>{/snippet}</EmptyState>{/if}
|
||||
<div class="footer"><span>{serverRows.length} of {servers.length} historical server names</span><span>A dash means no known time. Names may include more than one registration.</span></div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); min-width: 0; overflow: hidden; }
|
||||
.heading { padding: var(--space-5); display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; }
|
||||
h2 { font-size: var(--text-lg); margin: 0; }
|
||||
.heading p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.search { flex: 0 1 230px; min-width: 180px; }
|
||||
.definition { margin: 0; padding: 0 var(--space-5) var(--space-4); color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.6; max-width: 110ch; }
|
||||
.table-wrap { width: 100%; overflow-x: auto; }
|
||||
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -3px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
|
||||
th, td { padding: 14px var(--space-4); border-bottom: 1px solid var(--border-subtle); text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
th:first-child { text-align: left; min-width: 185px; max-width: 280px; white-space: normal; overflow-wrap: anywhere; }
|
||||
thead th { color: var(--text-secondary); background: var(--surface-sunken); font-size: var(--text-xs); }
|
||||
thead button { display: inline-flex; justify-content: flex-end; align-items: center; gap: 6px; color: inherit; background: transparent; font: inherit; padding: 5px 0; border: 0; cursor: pointer; text-align: inherit; white-space: nowrap; }
|
||||
thead button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 4px; }
|
||||
thead button span { color: var(--text-tertiary); }
|
||||
thead th[aria-sort] { color: var(--text-primary); }
|
||||
tbody th { font-weight: var(--weight-medium); }
|
||||
tbody tr:hover { background: color-mix(in oklab, var(--surface-raised) 35%, transparent); }
|
||||
small { display: block; margin-top: 4px; font-size: 11px; font-weight: normal; line-height: 1.5; color: var(--text-tertiary); }
|
||||
.server-id { font-family: var(--font-mono); font-size: 10px; }
|
||||
.unavailable { color: var(--text-secondary); }
|
||||
.player-hours { min-width: 120px; }
|
||||
.comparison { height: 3px; width: 90px; margin: 8px 0 0 auto; background: var(--surface-raised); border-radius: 3px; }
|
||||
.comparison i { display: block; height: 100%; background: var(--chart-active); border-radius: inherit; opacity: .7; }
|
||||
.footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2) var(--space-4); padding: var(--space-4) var(--space-5); font-size: 11px; color: var(--text-tertiary); line-height: 1.6; }
|
||||
@media (max-width: 640px) {
|
||||
.heading { padding: var(--space-4); }
|
||||
.search { flex: 1 1 100%; }
|
||||
.definition { padding-inline: var(--space-4); }
|
||||
th, td { padding: 12px; font-size: var(--text-xs); }
|
||||
th:first-child { position: sticky; left: 0; z-index: 1; min-width: 130px; max-width: 150px; background: var(--surface); box-shadow: 1px 0 var(--border-strong); }
|
||||
thead th:first-child { background: var(--surface-sunken); }
|
||||
.footer { padding: var(--space-3) var(--space-4); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { MetricsData } from '$lib/metrics.js';
|
||||
|
||||
interface Options {
|
||||
getQuery: () => string;
|
||||
onData: (report: MetricsData) => void;
|
||||
onError: (message: string | null) => void;
|
||||
onLoading: (loading: boolean) => void;
|
||||
fetcher?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Keeps background failures and obsolete responses out of the last successful report. */
|
||||
export function createMetricsRefresh(options: Options) {
|
||||
const request = options.fetcher ?? fetch;
|
||||
let generation = 0;
|
||||
let pending: { query: string; controller: AbortController; promise: Promise<void>; timer?: ReturnType<typeof setTimeout> } | null = null;
|
||||
|
||||
function cancel() {
|
||||
generation++;
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.controller.abort();
|
||||
pending = null;
|
||||
}
|
||||
options.onLoading(false);
|
||||
}
|
||||
|
||||
function refresh(): Promise<void> {
|
||||
const query = options.getQuery();
|
||||
if (pending?.query === query) return pending.promise;
|
||||
cancel();
|
||||
const id = generation;
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const current = () => generation === id && options.getQuery() === query;
|
||||
options.onLoading(true);
|
||||
options.onError(null);
|
||||
const entry = { query, controller, promise: Promise.resolve(), timer: undefined as ReturnType<typeof setTimeout> | undefined };
|
||||
pending = entry;
|
||||
entry.timer = setTimeout(() => { timedOut = true; controller.abort(); }, options.timeoutMs ?? 15_000);
|
||||
entry.promise = (async () => {
|
||||
try {
|
||||
const response = await request(`/api/metrics?${query}`, { signal: controller.signal, cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`Report refresh failed (${response.status}).`);
|
||||
const report = await response.json() as MetricsData;
|
||||
if (!report || !Array.isArray(report.points) || !Array.isArray(report.maps) || !Array.isArray(report.servers) || !report.summary) {
|
||||
throw new Error('The metrics response was incomplete.');
|
||||
}
|
||||
if (current()) options.onData(report);
|
||||
} catch (error) {
|
||||
if (current() && (!controller.signal.aborted || timedOut)) {
|
||||
options.onError(timedOut ? 'Report refresh timed out. Try again.' : error instanceof Error ? error.message : 'Unable to refresh metrics. Try again.');
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(entry.timer);
|
||||
if (pending === entry) pending = null;
|
||||
if (current()) options.onLoading(false);
|
||||
}
|
||||
})();
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
return { refresh, cancel };
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
const numberFormat = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
|
||||
|
||||
export function metricNumber(value: number): string {
|
||||
if (!Number.isFinite(value)) return '—';
|
||||
if (value > 0 && value < 0.1) return '<0.1';
|
||||
return numberFormat.format(value);
|
||||
}
|
||||
|
||||
export function metricPercent(value: number, total: number): string {
|
||||
if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return '—';
|
||||
const percent = 100 * value / total;
|
||||
return percent > 0 && percent < 0.1 ? '<0.1%' : `${percent.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/** Input is hours; retain short, real observations instead of rounding them to zero. */
|
||||
export function metricDuration(hours: number): string {
|
||||
if (!Number.isFinite(hours) || hours < 0) return '—';
|
||||
if (hours === 0) return '0 h';
|
||||
const seconds = hours * 3600;
|
||||
if (seconds < 1) return '<1 s';
|
||||
if (seconds < 60) return `${numberFormat.format(seconds)} s`;
|
||||
if (hours < 0.1) return `${numberFormat.format(seconds / 60)} min`;
|
||||
return `${numberFormat.format(hours)} h`;
|
||||
}
|
||||
|
||||
/** Missing evidence always sorts last, in either direction. */
|
||||
export function compareMetricValues(left: number | null, right: number | null, direction: 'asc' | 'desc'): number {
|
||||
const a = left !== null && Number.isFinite(left) ? left : null;
|
||||
const b = right !== null && Number.isFinite(right) ? right : null;
|
||||
if (a === null || b === null) return a === b ? 0 : a === null ? 1 : -1;
|
||||
return (a - b) * (direction === 'asc' ? 1 : -1);
|
||||
}
|
||||
@@ -78,6 +78,12 @@
|
||||
--accent-primary-hover: var(--blue-300);
|
||||
--accent-primary-active: #398fcf;
|
||||
|
||||
/* Metrics: unknown uses a pattern as well as color. */
|
||||
--chart-active: var(--accent-primary);
|
||||
--chart-idle: #7b9baf;
|
||||
--chart-unknown-base: #605b64;
|
||||
--chart-unknown-stripe: #a7a0ad;
|
||||
|
||||
/* Typography — Inter everywhere, mono for data.
|
||||
Display face lives inside BrandMark; no global --font-display token. */
|
||||
--font-body: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { parseMetricsDays } from '$lib/metrics.js';
|
||||
import { readMetrics } from '$lib/server/fleet/metrics.js';
|
||||
|
||||
export function GET({ url }: { url: URL }) {
|
||||
return json(readMetrics(parseMetricsDays(url.searchParams.get('days'))), {
|
||||
headers: { 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readMetrics } from '$lib/server/fleet/metrics.js';
|
||||
import { parseMetricsDays } from '$lib/metrics.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = ({ url }) => {
|
||||
const requested = Number(url.searchParams.get('days') ?? 7);
|
||||
return readMetrics([1, 7, 30].includes(requested) ? requested : 7);
|
||||
return readMetrics(parseMetricsDays(url.searchParams.get('days')));
|
||||
};
|
||||
|
||||
+123
-84
@@ -1,109 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
|
||||
import { createMetricsRefresh } from '$ui/metrics-refresh.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Card from '$ui/components/primitives/Card.svelte';
|
||||
import Stat from '$ui/components/primitives/Stat.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import MetricsChart from '$ui/components/metrics/MetricsChart.svelte';
|
||||
import MetricsTables from '$ui/components/metrics/MetricsTables.svelte';
|
||||
import type { MetricsData } from '$lib/metrics.js';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const percent = (value: number, total: number) => total > 0 ? `${(100 * value / total).toFixed(1)}%` : '—';
|
||||
const hours = (value: number) => `${value.toFixed(1)} h`;
|
||||
const peak = $derived(Math.max(1, ...data.points.map(p => p.active + p.idle + p.unknown)));
|
||||
const label = (at: number) => $timeFormat.period(at);
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => { if (!document.hidden) void invalidateAll(); }, 60_000);
|
||||
return () => clearInterval(timer);
|
||||
let snapshot = $state<MetricsData | null>(null);
|
||||
let refreshing = $state(false);
|
||||
let refreshError = $state<string | null>(null);
|
||||
const report = $derived(snapshot ?? data);
|
||||
const captured = $derived(report.summary.observed + report.summary.unknown);
|
||||
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
|
||||
const refresh = createMetricsRefresh({
|
||||
getQuery: () => new URLSearchParams({ days: String(data.days) }).toString(),
|
||||
onData: value => { snapshot = value; },
|
||||
onError: value => { refreshError = value; },
|
||||
onLoading: value => { refreshing = value; }
|
||||
});
|
||||
$effect(() => {
|
||||
// Navigation owns the new report; an older background request cannot replace it.
|
||||
void data;
|
||||
untrack(() => { refresh.cancel(); snapshot = null; refreshError = null; });
|
||||
});
|
||||
onMount(() => {
|
||||
const stop = pollVisible(() => refresh.refresh(), 60_000, false);
|
||||
return () => { stop(); refresh.cancel(); };
|
||||
});
|
||||
function rangeHref(days: number): string {
|
||||
const params = new URLSearchParams(contextSearch);
|
||||
params.set('days', String(days));
|
||||
return `/metrics?${params}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Metrics · Electric Cherry</title></svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<SectionHeader title="Metrics" subtitle="Plan hosting around server utilization, map play time, and capacity pressure." />
|
||||
<SectionHeader title="Metrics" subtitle="Understand server use, map play time, and measured capacity pressure.">
|
||||
{#snippet actions()}<Button size="sm" loading={refreshing} onclick={() => refresh.refresh()}>Refresh</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
<div class="toolbar">
|
||||
<nav aria-label="Metrics time range">
|
||||
{#each [1, 7, 30] as days}
|
||||
<a href={`/metrics?days=${days}`} aria-current={data.days === days ? 'page' : undefined}>{days === 1 ? '24 hours' : `${days} days`}</a>
|
||||
<a href={rangeHref(days)} aria-current={report.days === days ? 'page' : undefined} data-sveltekit-keepfocus data-sveltekit-noscroll>{days === 1 ? '24 hours' : `${days} days`}</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<span>Updated {$timeFormat.time(data.now)} · refreshes every minute</span>
|
||||
<span class="range">{$timeFormat.period(report.since)} – {$timeFormat.period(report.now)}</span>
|
||||
</div>
|
||||
|
||||
{#if data.servers.length === 0}
|
||||
<section><h2>Collecting your first observations</h2><p>Metrics start with the next server status checks. Leave the manager running to build a history of busy hours and map demand.</p></section>
|
||||
{:else}
|
||||
<div class="stats">
|
||||
<section><span>Servers utilized</span><strong>{percent(data.summary.active, data.summary.observed)}</strong><small>{hours(data.summary.active)} with players</small></section>
|
||||
<section><span>Servers idle</span><strong>{percent(data.summary.idle, data.summary.observed)}</strong><small>{hours(data.summary.idle)} empty</small></section>
|
||||
<section><span>Player time</span><strong>{hours(data.summary.playerHours)}</strong><small>Players × observed time</small></section>
|
||||
<section><span>Unknown status</span><strong>{hours(data.summary.unknown)}</strong><small>Excluded from utilization percentages</small></section>
|
||||
{#if refreshError}
|
||||
<div class="refresh-error" role="alert">
|
||||
<div><strong>Could not refresh metrics.</strong> {refreshError}<small>Showing the report from {$timeFormat.dateTime(report.now)}.</small></div>
|
||||
<Button size="sm" loading={refreshing} onclick={() => refresh.refresh()}>Retry</Button>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Utilization over time</h2>
|
||||
<p>Server-hours per {data.days === 1 ? 'hour' : '24-hour period'}. Period starts are shown in {$timeZone.replaceAll('_', ' ')}. Taller bars mean more time under observation.</p>
|
||||
<div class="legend"><span><i class="active"></i>Utilized</span><span><i class="idle"></i>Idle</span><span><i class="unknown"></i>Unknown</span></div>
|
||||
<div class="chart" role="img" aria-label="Stacked server-hours chart. Exact values are in the time data table below.">
|
||||
{#each data.points as point}
|
||||
<div class="bar" title={`${label(point.at)}: ${hours(point.active)} utilized, ${hours(point.idle)} idle, ${hours(point.unknown)} unknown`}>
|
||||
<div class="unknown" style:height={`${point.unknown / peak * 100}%`}></div>
|
||||
<div class="idle" style:height={`${point.idle / peak * 100}%`}></div>
|
||||
<div class="active" style:height={`${point.active / peak * 100}%`}></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="axis"><span>{label(data.points[0].at)}</span><span>0–{hours(peak)} per bar</span><span>{label(data.points[data.points.length - 1].at)}</span></div>
|
||||
<details><summary>View time data</summary><div class="table-wrap"><table><thead><tr><th>Period start ({$timeZone.replaceAll('_', ' ')})</th><th>Utilized</th><th>Idle</th><th>Unknown</th><th>Player time</th></tr></thead><tbody>{#each data.points as point}<tr><td>{label(point.at)}</td><td>{hours(point.active)}</td><td>{hours(point.idle)}</td><td>{hours(point.unknown)}</td><td>{hours(point.playerHours)}</td></tr>{/each}</tbody></table></div></details>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Map play time & capacity pressure</h2>
|
||||
<p>Ranked by player-hours. “All lobbies full” measures time when every tracked lobby for a map has known capacity and is full. It indicates capacity pressure; failed joins and unmet demand are not tracked.</p>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Map</th><th>Player time</th><th>Utilized server time</th><th>All lobbies full</th><th>Capacity coverage</th></tr></thead>
|
||||
<tbody>{#each data.maps as map}<tr><td>{map.map}<small>{map.game.toUpperCase()}</small></td><td>{hours(map.playerHours)}</td><td>{hours(map.active)}</td><td>{map.capacityObserved > 0 ? `${hours(map.full)} (${percent(map.full, map.capacityObserved)})` : 'Unknown'}</td><td>{hours(map.capacityObserved)}</td></tr>{/each}</tbody>
|
||||
</table></div>
|
||||
<p class="note">Capacity coverage is map time with a known player limit and status for every tracked lobby. Servers that do not report a player limit cannot establish whether all lobbies are full.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Server utilization</h2>
|
||||
<div class="table-wrap"><table><thead><tr><th>Server</th><th>Utilized</th><th>Idle</th><th>Observed time</th><th>Unknown</th></tr></thead>
|
||||
<tbody>{#each data.servers as server}<tr><td>{server.server}</td><td>{percent(server.active, server.observed)}</td><td>{percent(server.idle, server.observed)}</td><td>{hours(server.observed)}</td><td>{hours(server.unknown)}</td></tr>{/each}</tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
{/if}
|
||||
<p class="note">Estimates use periodic server status observations. Each observation covers at most 45 seconds; longer collection gaps are excluded. History begins when collection is enabled and is retained for 31 days. Player time includes all players reported by the server.</p>
|
||||
{#if captured > 0}
|
||||
<div class="stats">
|
||||
<Card>
|
||||
<Stat label="Time with players" value={metricPercent(report.summary.active, report.summary.observed)} size="lg" tween={false} />
|
||||
<p class="stat-context">{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} known server time</p>
|
||||
{#if report.summary.observed === 0}<p class="stat-note">Occupancy is unknown for all captured time.</p>{/if}
|
||||
</Card>
|
||||
<Card>
|
||||
<Stat label="Player-hours" value={report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'} size="lg" tween={false} />
|
||||
<p class="stat-context">Players × known time · one player for one hour = 1</p>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="context" aria-label="About this report">
|
||||
{#if captured > 0}<span><strong>{metricDuration(report.summary.observed)}</strong> known server time</span><span><strong>{metricDuration(report.summary.unknown)}</strong> unknown status</span>{/if}
|
||||
<span>Last collected <strong>{report.latestSampleAt === null ? 'No retained samples' : $timeFormat.dateTime(report.latestSampleAt)}</strong></span>
|
||||
<span>Report refreshed {$timeFormat.time(report.now)} · refreshes every minute while visible</span>
|
||||
{#if report.firstRetainedSampleAt !== null}<span>Earliest retained sample {$timeFormat.dateTime(report.firstRetainedSampleAt)}</span>{/if}
|
||||
</div>
|
||||
{#if captured === 0}
|
||||
<Card>
|
||||
<EmptyState title={report.latestSampleAt === null ? 'No retained observations yet' : report.servers.length > 0 ? 'Waiting for observed time' : 'No captured time in this range'}
|
||||
description={report.latestSampleAt === null
|
||||
? report.registeredServerCount > 0 ? 'Leave the manager running to collect server status and build a history.' : 'Register a server to begin collecting utilization history.'
|
||||
: report.servers.length > 0 ? 'The first sample has arrived. Time builds between status checks; this report has no elapsed observations yet.'
|
||||
: 'There are no elapsed observations in the selected window. Collection gaps are excluded from these estimates.'}>
|
||||
{#snippet actions()}
|
||||
{#if report.latestSampleAt !== null && report.days < 30}<Button href={rangeHref(30)} size="sm">View 30 days</Button>{/if}
|
||||
{#if report.registeredServerCount === 0}<Button href="/" size="sm">View fleet</Button>{/if}
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
</Card>
|
||||
{:else}
|
||||
{#if report.registeredServerCount === 0}
|
||||
<p class="notice">No servers are currently registered. This report shows retained history; new samples resume when servers are registered.</p>
|
||||
{:else if report.latestSampleAt !== null && report.now - report.latestSampleAt > 5 * 60_000}
|
||||
<p class="notice">No new collection samples in the last five minutes. This report still includes the retained observations shown below.</p>
|
||||
{/if}
|
||||
{#if report.summary.observed === 0}<p class="notice">All captured server status is unknown. A dash means occupancy could not be calculated; it does not mean servers were empty.</p>{/if}
|
||||
<MetricsChart points={report.points} days={report.days} />
|
||||
<MetricsTables maps={report.maps} servers={report.servers} />
|
||||
{/if}
|
||||
<details class="methodology">
|
||||
<summary>How these estimates work</summary>
|
||||
<p>Time with players is occupied server time divided by known server time. Known time includes occupied and idle observations; unknown status and collection gaps are excluded from that percentage.</p>
|
||||
<p>Each periodic observation covers at most 45 seconds. Longer collection gaps are excluded. History is retained for 31 days. Player-hours include all players reported by the server and are estimates, not individual session records.</p>
|
||||
<p>Chart buckets are aligned to UTC, with labels shown in {$timeZone.replaceAll('_', ' ')}. Daily buckets are 24-hour periods, not local calendar days. The first and latest buckets can be partial.</p>
|
||||
<p>Historical server rows are grouped by name and may include multiple registrations using that name. Map groups preserve the recorded game and map label.</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page { display: grid; gap: var(--space-6); min-width: 0; }
|
||||
section { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); min-width: 0; }
|
||||
h2 { font-size: var(--text-lg); margin: 0 0 12px; }
|
||||
p { color: var(--text-secondary); margin: 8px 0 16px; }
|
||||
.toolbar, .axis, .legend { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||
.toolbar { align-items: center; }
|
||||
.toolbar span, .axis, .note, small { color: var(--text-tertiary); font-size: var(--text-sm); }
|
||||
nav { display: flex; gap: 8px; }
|
||||
nav a { padding: 8px 12px; border-radius: var(--radius-md); text-decoration: none; }
|
||||
.page { display: grid; gap: var(--space-5); min-width: 0; }
|
||||
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
|
||||
nav { display: flex; gap: var(--space-1); padding: 4px; border: 1px solid var(--border-subtle); background: var(--surface); border-radius: var(--radius-md); }
|
||||
nav a { padding: 9px 12px; color: var(--text-secondary); border-radius: var(--radius-sm); text-decoration: none; white-space: nowrap; font-size: var(--text-sm); }
|
||||
nav a[aria-current] { background: var(--surface-raised); color: var(--text-primary); }
|
||||
.stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
|
||||
.stats section { display: grid; gap: 8px; }
|
||||
strong { font-size: 30px; font-variant-numeric: tabular-nums; }
|
||||
small { display: block; }
|
||||
.legend { justify-content: flex-start; font-size: var(--text-sm); }
|
||||
.legend span { display: flex; align-items: center; gap: 6px; }
|
||||
i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; }
|
||||
.active { background: var(--accent-primary); }
|
||||
.idle { background: #7b9baf; }
|
||||
.unknown { background: repeating-linear-gradient(135deg, #605b64 0 3px, #a7a0ad 3px 5px); }
|
||||
.chart { display: flex; height: 200px; gap: 4px; border-bottom: 1px solid var(--border-strong); margin: 20px 0 8px; }
|
||||
.bar { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: flex-end; }
|
||||
details { margin-top: 20px; } summary { cursor: pointer; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; text-align: left; font-size: var(--text-sm); }
|
||||
th, td { padding: 12px; border-bottom: 1px solid var(--border-subtle); font-variant-numeric: tabular-nums; }
|
||||
th { color: var(--text-secondary); white-space: nowrap; }
|
||||
@media (max-width: 800px) { .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 450px) { .stats { grid-template-columns: 1fr; } }
|
||||
nav a:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.range, .context, .methodology, .notice { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.6; }
|
||||
.range { max-width: 50ch; }
|
||||
.stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); }
|
||||
.stat-context { margin: 10px 0 0; font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.5; }
|
||||
.stat-note { margin: 6px 0 0; color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.context { display: flex; flex-wrap: wrap; column-gap: var(--space-5); row-gap: var(--space-1); padding: 0 2px; }
|
||||
.context strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.refresh-error { display: flex; justify-content: space-between; align-items: center; gap: var(--space-4); border: 1px solid var(--state-warning); padding: var(--space-4); border-radius: var(--radius-md); font-size: var(--text-sm); }
|
||||
.refresh-error small { display: block; margin-top: var(--space-1); color: var(--text-secondary); }
|
||||
.notice { margin: 0; border-left: 2px solid var(--state-warning); padding: var(--space-2) var(--space-3); color: var(--text-secondary); }
|
||||
.methodology { padding: var(--space-3) 2px; }
|
||||
.methodology summary { cursor: pointer; color: var(--text-secondary); }
|
||||
.methodology p { max-width: 110ch; margin: var(--space-3) 0 0; }
|
||||
@media (max-width: 540px) {
|
||||
.page { gap: var(--space-4); }
|
||||
.stats { gap: var(--space-2); }
|
||||
.stats :global(.card) { padding: var(--space-3); }
|
||||
.stats :global(.value) { font-size: var(--text-xl); }
|
||||
.stats :global(.label) { font-size: 11px; }
|
||||
.stat-context { font-size: var(--text-xs); }
|
||||
.context { font-size: 11px; column-gap: var(--space-3); }
|
||||
.refresh-error { align-items: flex-start; flex-wrap: wrap; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user