Simplify metrics overview and paginate breakdowns

This commit is contained in:
2026-10-06 19:02:02 -04:00
parent b8cccc5a98
commit 01d764946c
7 changed files with 353 additions and 227 deletions
+39 -53
View File
@@ -2,15 +2,17 @@
import { onMount, untrack } from 'svelte';
import { ONLINE_MAX_MS } from '$lib/server-freshness.js';
import { page } from '$app/state';
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
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, metricPercent } from '$ui/metrics.js';
import { metricDuration } 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 MetricsSummary from '$ui/components/metrics/MetricsSummary.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';
@@ -33,6 +35,11 @@
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') === 'instances' ? 'instances' : 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' },
@@ -76,6 +83,7 @@
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;
@@ -91,6 +99,7 @@
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}`;
}
@@ -99,21 +108,17 @@
<svelte:head><title>Metrics · Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Metrics" subtitle="Understand server use, map play time, and measured capacity pressure.">
{#snippet actions()}<Button size="sm" loading={refreshing} disabled={scopeNavigating} onclick={() => refresh.refresh()}>Refresh</Button>{/snippet}
</SectionHeader>
<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>
<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 class="refresh-controls"><span role="status">{scopeNavigating ? 'Updating report…' : `Updated ${format.minuteTime(report.now)}`}</span><Button size="sm" variant="ghost" loading={refreshing} disabled={scopeNavigating} onclick={() => refresh.refresh()}>Refresh</Button></div>
</div>
{#if scopeError}<p class="notice" role="alert">{scopeError}</p>{/if}
{#if refreshError}
@@ -122,26 +127,8 @@
<Button size="sm" loading={refreshing} onclick={() => refresh.refresh()}>Retry</Button>
</div>
{/if}
{#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>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 class="range">{scopeLabel} · {format.period(report.since)} – {format.period(report.now)}</p>
{#if captured > 0}<MetricsSummary {report} />{/if}
{#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 scope'}
@@ -165,10 +152,23 @@
{#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} 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}
{#if captured > 0}
<details class="weekly"><summary>Activity by weekday and hour</summary><MetricsHeatmap heatmap={report.heatmap} disabled={scopeNavigating} onTimeZoneChange={value => changeScope({ timeZone: value })} /></details>
<Tabs tabs={[{ id: 'maps', label: 'Maps' }, { id: 'instances', label: 'Instances' }, { id: 'insights', label: 'Hosting insights' }]} 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} servers={report.servers} {view} />{/if}
</div>
{/if}
<details class="methodology">
<summary>How these estimates work</summary>
<summary>About this report</summary>
<div class="context">
{#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>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)}. Known time: {metricDuration(report.previous.summary.observed)} previous / {metricDuration(report.summary.observed)} current. pp means percentage points. Equal windows do not guarantee equal observation coverage.</p>
<p>Time with players is occupied server time divided by known server time. Known time includes occupied and idle observations; unknown status and collection gaps are excluded from that percentage.</p>
<p>Each periodic observation covers at most {ONLINE_MAX_MS / 1000} 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>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>
@@ -178,36 +178,22 @@
<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; }
.toolbar { display: flex; align-items: end; 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); }
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); }
.range { margin: 0; }
.refresh-controls { display: flex; align-items: center; gap: 8px; margin-left: auto; font-size: var(--text-xs); color: var(--text-secondary); }
.weekly { font-size: var(--text-sm); color: var(--text-secondary); }
.weekly summary { cursor: pointer; }
.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) {
.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); }
.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; }
}
@media (max-width: 540px) { .toolbar { align-items: stretch; } .refresh-controls { margin-left: 0; } .refresh-error { flex-wrap: wrap; } }
</style>