Files
electric-cherry/src/routes/metrics/+page.svelte
T
2026-10-10 18:47:56 -04:00

555 lines
22 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { ONLINE_MAX_MS } from '$lib/server-freshness.js';
import { page } from '$app/state';
import { afterNavigate, beforeNavigate, goto, replaceState } 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 { metricDuration, metricNumber } 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 MetricsSummary from '$ui/components/metrics/MetricsSummary.svelte';
import MetricsAutoscaling from '$ui/components/metrics/MetricsAutoscaling.svelte';
import MetricsHeatmap from '$ui/components/metrics/MetricsHeatmap.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import Button from '$ui/components/primitives/Button.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 MetricsPlayers from '$ui/components/metrics/MetricsPlayers.svelte';
import MetricsExplore from '$ui/components/metrics/MetricsExplore.svelte';
import MetricsTables from '$ui/components/metrics/MetricsTables.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import type { MetricsData } from '$lib/metrics.js';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let snapshot = $state<MetricsData | null>(null);
let refreshing = $state(false);
let refreshError = $state<string | null>(null);
let scopeError = $state('');
let scopeNavigating = $state(false);
let mounted = $state(false);
let attemptedPreference = $state<string | null>(null);
let checkedAt = $state(untrack(() => Date.now()));
const report = $derived(snapshot ?? data);
const format = $derived(createTimeFormatter(report.scope.timeZone));
const captured = $derived(report.summary.observed + report.summary.unknown);
const contextSearch = $derived(
(page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search
);
const view = $derived(
new URLSearchParams(contextSearch).get('view') === 'insights' ? 'insights' : 'maps'
);
function changeView(value: string) {
const url = new URL(page.url);
url.search = contextSearch;
url.searchParams.set('view', value);
replaceState(url, { ...page.state, metricsTableSearch: 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({
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;
checkedAt = Date.now();
},
onError: (value) => {
refreshError = value;
},
onLoading: (value) => {
refreshing = value;
}
});
beforeNavigate(() => {
refresh.cancel();
scopeNavigating = true;
});
afterNavigate(() => {
scopeNavigating = false;
attemptedPreference = null;
});
$effect(() => {
// Navigation owns the new report; an older background request cannot replace it.
void data;
untrack(() => {
refresh.cancel();
snapshot = null;
refreshError = null;
checkedAt = Date.now();
});
});
$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(() => {
mounted = true;
const stop = pollVisible(
() => (scopeNavigating ? undefined : refresh.refresh()),
60_000,
false
);
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);
}
params.delete('mapPage');
params.delete('serverPage');
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 {
const params = new URLSearchParams(contextSearch);
params.set('days', String(days));
params.delete('mapPage');
params.delete('serverPage');
params.set('timeZone', report.scope.timeZone);
return `/metrics?${params}`;
}
</script>
<svelte:head><title>Metrics · Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Metrics" />
<div class="toolbar">
<nav aria-label="Metrics time range">
{#each [1, 7, 30] as days}
<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>
<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
>
<div class="refresh-controls">
{#if scopeNavigating}<span role="status">Updating report…</span
>{:else if !refreshError}<Freshness
updatedAt={checkedAt}
intervalLabel="Updates every minute"
/>{/if}
</div>
</div>
{#if scopeError}<p class="notice" role="alert">{scopeError}</p>{/if}
{#if refreshError}
<div class="refresh-error" role="alert">
<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>
</div>
{/if}
<p class="range">{scopeLabel} · {format.period(report.since)} – {format.period(report.now)}</p>
<MetricsSummary {report} />
<MetricsAutoscaling metrics={report.autoscaling} />
{#if captured === 0}
<Card>
<EmptyState
title={report.latestSampleAt === null
? 'No retained observations yet'
: report.sampledServerCount > 0
? 'Waiting for observed time'
: 'No captured time in this scope'}
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.sampledServerCount > 0
? 'Captured samples have no elapsed time in this window. Time builds between status checks.'
: 'There are no elapsed observations for the selected game, map, and time window. Try another scope or a wider range.'}
>
{#snippet actions()}
{#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}
{/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 lobby status is unknown. Running counts include these lobbies, but
their occupancy and availability could not be determined.
</p>{/if}
<MetricsChart points={report.points} days={report.days} timeZone={report.scope.timeZone} />
{/if}
<MetricsPlayers
points={report.points}
players={report.players}
days={report.days}
since={report.since}
timeZone={report.scope.timeZone}
/>
{#if captured > 0}
<MetricsHeatmap
heatmap={report.heatmap}
disabled={scopeNavigating}
onTimeZoneChange={(value) => changeScope({ timeZone: value })}
/>
{/if}
{#if captured > 0 || report.matches.started > 0}
<Tabs
tabs={[
{ id: 'maps', label: 'Maps' },
{ id: 'insights', label: 'Autoscaling signals' }
]}
value={view}
onchange={changeView}
ariaLabel="Metrics breakdown"
idPrefix="metrics-views"
panelId="metrics-breakdown"
/>
<div id="metrics-breakdown" role="tabpanel" aria-labelledby={`metrics-views-${view}`}>
{#if view === 'insights'}<MetricsExplore {report} />{:else}<MetricsTables
maps={report.maps}
/>{/if}
</div>
{/if}
<details class="methodology">
<summary>About this report</summary>
<div class="context">
{#if captured > 0}<span
><strong>{metricDuration(report.summary.observed)}</strong> known lobby time</span
><span
><strong>{metricDuration(report.summary.unknown)}</strong> unknown status</span
>{/if}
<span
>Scope last collected <strong
>{report.scopeLatestSampleAt === null
? 'No retained samples'
: format.dateTime(report.scopeLatestSampleAt)}</strong
></span
>
<span
>Fleet last collected <strong
>{report.latestSampleAt === null
? 'No retained samples'
: format.dateTime(report.latestSampleAt)}</strong
></span
>
<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>
<p>
Previous window: {format.period(report.previous.since)} – {format.period(
report.previous.until
)}. Average lobbies running: {report.previous.running.average === null
? '—'
: metricNumber(report.previous.running.average)} previous /
{report.running.average === null ? '—' : metricNumber(report.running.average)} current. Captured
wall time: {metricDuration(report.previous.running.capturedHours)} previous /
{metricDuration(report.running.capturedHours)} current. Equal windows do not guarantee equal
observation coverage.
</p>
<p>
Lobbies running averages the concurrent lobby count over captured wall time and includes
lobbies with unknown status. Peak is the largest captured concurrent count. Empty spare
share divides lobby time with zero reported players by all captured lobby-hours,
including unknown status. Empty spares are expected when an autoscaling policy keeps
lobbies ready.
</p>
<p>
Matches started counts retained matches whose start time falls within this report’s
window, including both endpoints and all match statuses. If a match did not report a
start time, its first observation is used. Average group is the mean number of distinct
recorded players across each match’s lifetime, including later arrivals. Solo means
exactly one recorded player. Both average group and solo share exclude matches with no
recorded players; those matches still count as started. Ongoing matches can change as
players join. Match records are independent of sampled lobby occupancy, and missing or
purged records are not estimated.
</p>
<p>
No empty lobby measures map time when at least one lobby has known occupancy and no
tracked lobby reports zero players. If every lobby is unknown, map availability is
unknown and excluded from the percentage. Player limits are not required. This
availability signal does not establish failed joins or unmet demand. Autoscaling signals
compare observations with the current policy; historical policy settings are not
recorded.
</p>
<p>
Autoscaling counts retained Provisioning operations whose finish time falls within this
window, including both endpoints. Successful creates and removals include operations
completed with warnings; failed and cancelled operations are counted separately. Pending
operations are excluded. Request-to-ready time measures completed creates from request
through verified readiness, including queue time, and does not establish browser
visibility or successful joins. Game and map scope use the operation’s recorded server
name, preserving map variants and history after server or policy removal.
</p>
<p>
Each periodic observation covers at most {ONLINE_MAX_MS / 1000} seconds. Longer collection
gaps are excluded. Occupancy history is retained {report.metricsKeepDays === 0
? 'indefinitely'
: `for ${report.metricsKeepDays} ${report.metricsKeepDays === 1 ? 'day' : 'days'}`};
saved match retention is configured separately. Player-hours include all players
reported by the server and are estimates, not individual session records.
</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. Average bars divide occupied, empty spare, and unknown
lobby-hours by captured wall time within each bucket, so a partial period is not a lower
lobby count merely because it is shorter. Percentage bars use all captured lobby time.
</p>
<p>
Map groups preserve the recorded game and map label. Each map’s average and peak count
concurrent lobbies during its captured wall time, including unknown occupancy. Empty
lobby-hours sum time with zero reported players across that map’s lobbies. Per-lobby
history is available on server pages.
</p>
</details>
</div>
<style>
.page {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-5);
min-width: 0;
}
.toolbar {
display: flex;
align-items: end;
gap: var(--space-3);
flex-wrap: wrap;
}
.toolbar :global(.field) {
flex: 1 1 180px;
max-width: 300px;
}
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);
}
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 {
margin: 0;
}
.refresh-controls {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.context {
display: flex;
flex-wrap: wrap;
column-gap: var(--space-5);
row-gap: var(--space-1);
padding: 0 2px;
}
.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) {
.toolbar {
align-items: stretch;
}
.toolbar :global(.field) {
flex-basis: 100%;
max-width: none;
}
.refresh-controls {
margin-left: 0;
}
.refresh-error {
flex-wrap: wrap;
}
}
</style>