Add experimental metrics insights, comparisons, and scope filters
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
|
||||
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
|
||||
import { timeZone } from '$ui/stores/time-zone.js';
|
||||
import { createTimeFormatter } from '$lib/time-zone.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
|
||||
import { createMetricsRefresh } from '$ui/metrics-refresh.js';
|
||||
@@ -10,7 +12,10 @@
|
||||
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 Field from '$ui/components/primitives/Field.svelte';
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
import MetricsChart from '$ui/components/metrics/MetricsChart.svelte';
|
||||
import MetricsExplore from '$ui/components/metrics/MetricsExplore.svelte';
|
||||
import MetricsTables from '$ui/components/metrics/MetricsTables.svelte';
|
||||
import type { MetricsData } from '$lib/metrics.js';
|
||||
import type { PageData } from './$types';
|
||||
@@ -19,27 +24,73 @@
|
||||
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);
|
||||
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 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: () => new URLSearchParams({ days: String(data.days) }).toString(),
|
||||
getQuery: () => {
|
||||
const params = new URLSearchParams({ days: String(data.days), timeZone: data.scope.timeZone });
|
||||
if (data.scope.game) params.set('game', data.scope.game);
|
||||
if (data.scope.map) params.set('map', data.scope.map);
|
||||
return params.toString();
|
||||
},
|
||||
onData: value => { snapshot = value; },
|
||||
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; });
|
||||
});
|
||||
$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(() => {
|
||||
const stop = pollVisible(() => refresh.refresh(), 60_000, false);
|
||||
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); }
|
||||
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.set('timeZone', report.scope.timeZone);
|
||||
return `/metrics?${params}`;
|
||||
}
|
||||
</script>
|
||||
@@ -48,7 +99,7 @@
|
||||
|
||||
<div class="page">
|
||||
<SectionHeader title="Metrics" subtitle="Understand server use, map play time, and measured capacity pressure.">
|
||||
{#snippet actions()}<Button size="sm" loading={refreshing} onclick={() => refresh.refresh()}>Refresh</Button>{/snippet}
|
||||
{#snippet actions()}<Button size="sm" loading={refreshing} disabled={scopeNavigating} onclick={() => refresh.refresh()}>Refresh</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
<div class="toolbar">
|
||||
<nav aria-label="Metrics time range">
|
||||
@@ -56,11 +107,17 @@
|
||||
<a href={rangeHref(days)} aria-current={report.days === days ? 'page' : undefined} data-sveltekit-keepfocus data-sveltekit-noscroll>{days === 1 ? '24 hours' : `${days} days`}</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<span class="range">{$timeFormat.period(report.since)} – {$timeFormat.period(report.now)}</span>
|
||||
<span class="range">{format.period(report.since)} – {format.period(report.now)}</span>
|
||||
</div>
|
||||
<div class="scope-controls" aria-label="Filter the entire metrics report">
|
||||
<Field label="Game"><Select value={report.scope.game ?? ''} options={gameOptions} disabled={scopeNavigating} onchange={event => changeScope({ game: (event.currentTarget as HTMLSelectElement).value, map: '' })} /></Field>
|
||||
<Field label="Map"><Select value={mapValue} options={mapOptions} disabled={scopeNavigating} onchange={event => changeMap((event.currentTarget as HTMLSelectElement).value)} /></Field>
|
||||
<p class="scope-note" role="status">{scopeNavigating ? 'Updating report…' : `${scopeLabel} · applies to every metric, chart, and table`}</p>
|
||||
</div>
|
||||
{#if scopeError}<p class="notice" role="alert">{scopeError}</p>{/if}
|
||||
{#if refreshError}
|
||||
<div class="refresh-error" role="alert">
|
||||
<div><strong>Could not refresh metrics.</strong> {refreshError}<small>Showing the report from {$timeFormat.dateTime(report.now)}.</small></div>
|
||||
<div><strong>Could not refresh metrics.</strong> {refreshError}<small>Showing the report from {format.dateTime(report.now)}.</small></div>
|
||||
<Button size="sm" loading={refreshing} onclick={() => refresh.refresh()}>Retry</Button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -79,19 +136,21 @@
|
||||
{/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}
|
||||
<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>
|
||||
{#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'}
|
||||
<EmptyState title={report.latestSampleAt === null ? 'No retained observations yet' : report.servers.length > 0 ? 'Waiting for observed time' : 'No captured time in this scope'}
|
||||
description={report.latestSampleAt === null
|
||||
? 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.'}>
|
||||
: report.servers.length > 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>
|
||||
@@ -103,14 +162,15 @@
|
||||
<p class="notice">No new collection samples in the last five minutes. This report still includes the retained observations shown below.</p>
|
||||
{/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} />
|
||||
<MetricsChart points={report.points} days={report.days} timeZone={report.scope.timeZone} />
|
||||
{/if}
|
||||
<MetricsExplore {report} changingScope={scopeNavigating} onTimeZoneChange={value => changeScope({ timeZone: value })} />
|
||||
{#if captured > 0}<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>Timeline buckets are aligned to UTC, with labels shown in {report.scope.timeZone.replaceAll('_', ' ')}. Daily buckets are 24-hour periods, not local calendar days. The first and latest buckets can be partial. Percentage bars divide occupied, idle, and unknown time by captured time; the headline occupancy percentage uses known time only.</p>
|
||||
<p>Historical server rows are grouped by name and may include multiple registrations using that name. Map groups preserve the recorded game and map label.</p>
|
||||
</details>
|
||||
</div>
|
||||
@@ -118,6 +178,8 @@
|
||||
<style>
|
||||
.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; }
|
||||
.scope-controls { display: grid; grid-template-columns: minmax(140px, 220px) minmax(180px, 300px) minmax(140px, 1fr); gap: var(--space-3); align-items: end; }
|
||||
.scope-note { color: var(--text-secondary); font-size: var(--text-xs); margin: 0 0 8px; line-height: 1.5; }
|
||||
nav { display: flex; gap: var(--space-1); padding: 4px; border: 1px solid var(--border-subtle); background: var(--surface); border-radius: var(--radius-md); }
|
||||
nav 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); }
|
||||
@@ -136,6 +198,8 @@
|
||||
.methodology summary { cursor: pointer; color: var(--text-secondary); }
|
||||
.methodology p { max-width: 110ch; margin: var(--space-3) 0 0; }
|
||||
@media (max-width: 540px) {
|
||||
.scope-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.scope-note { grid-column: 1 / -1; margin: 0; }
|
||||
.page { gap: var(--space-4); }
|
||||
.stats { gap: var(--space-2); }
|
||||
.stats :global(.card) { padding: var(--space-3); }
|
||||
|
||||
Reference in New Issue
Block a user