555 lines
22 KiB
Svelte
555 lines
22 KiB
Svelte
<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>
|