diff --git a/src/lib/ui/components/metrics/MetricsChart.svelte b/src/lib/ui/components/metrics/MetricsChart.svelte index 1f584cf..5b953ae 100644 --- a/src/lib/ui/components/metrics/MetricsChart.svelte +++ b/src/lib/ui/components/metrics/MetricsChart.svelte @@ -65,7 +65,7 @@
-

Server time over time

{mode === 'percent' ? 'Share of captured server time, including unknown status.' : `Server-hours per ${days === 1 ? 'hour' : '24-hour period'}.`} Partial periods contain less elapsed time.

+

Instance activity over time

{mode === 'percent' ? 'Share of captured server time, including unknown status.' : `Server-hours per ${days === 1 ? 'hour' : '24-hour period'}.`} Partial periods contain less elapsed time.

With playersIdleUnknown* Partial period
@@ -91,6 +91,8 @@
{#if selected} +
{captured ? `${metricDuration(selected.active)} with players · ${metricDuration(selected.idle)} idle · ${metricDuration(selected.unknown)} unknown · ${selected.observed ? metricNumber(selected.playerHours) : '—'} player-hours` : 'No captured time · not evidence of idle instances'}
+
Inspect a period
updateContext('insightQ', (event.currentTarget as HTMLInputElement).value)} /> updateContext({ mapQ: (event.currentTarget as HTMLInputElement).value })} />
+
+
+

{view === 'maps' ? 'Map play time and capacity' : 'Instance utilization'}

+
-

“All lobbies full” uses map time when every tracked lobby has known occupancy and capacity. It does not measure failed joins or unmet demand.

- {#if mapRows.length} + {#if currentPage.total} -
- {#each mapColumns as column}{/each} - {#each mapRows as map (JSON.stringify([map.game, map.map]))} - - - - - - {/each} +
{map.map}{gameName(map.game)} · {map.game.toUpperCase()}{map.observed ? metricNumber(map.playerHours) : '—'}{metricDuration(map.active)}server time{#if map.capacityObserved > 0}{metricDuration(map.full)}{metricPercent(map.full, map.capacityObserved)} during {metricDuration(map.capacityObserved)} known{:else}UnknownNo known capacity time{/if}{metricDuration(map.capacityObserved)}map time
+ {#each view === 'maps' ? mapColumns : serverColumns as column} + + {/each} + + {#if view === 'maps'} + {#each mapPage.rows as map (JSON.stringify([map.game, map.map]))} + + + + + + {/each} + {:else} + {#each serverPage.rows as server (server.server)} + + + + {/each} + {/if} +
+ {#if column.key === 'details'}Details{:else}{/if} +
{map.map}{gameName(map.game)}{map.observed < 1 || map.capacityObserved < 1 || map.unknown > map.observed / 4 ? ' · Limited evidence' : ''}{map.observed ? metricNumber(map.playerHours) : '—'}{map.observed ? metricDuration(map.active) : '—'}{metricPercent(map.full, map.capacityObserved)}
{map.map} Details
+
Observed instance time
{metricDuration(map.observed)}
Unknown status
{metricDuration(map.unknown)}
All lobbies full
{map.capacityObserved ? `${metricDuration(map.full)} of ${metricDuration(map.capacityObserved)} known map time` : 'Capacity data unavailable'}
+
{#if server.currentRegistered}{serverTitle(server.server)}{:else}{serverTitle(server.server)}Historical{/if}{metricPercent(server.active, server.observed)}{metricDuration(server.observed)}{metricDuration(server.unknown)}
{serverTitle(server.server)} Details
Recorded name
{server.server}
Time with players
{metricDuration(server.active)}
Idle
{metricPercent(server.idle, server.observed)} · {metricDuration(server.idle)}
- {:else}{#snippet actions()}{#if mapQuery}{/if}{/snippet}{/if} - -
- -
-

Server utilization

Time with players as a share of each server’s known time.

- + {:else}{#snippet actions()}{/snippet}{/if} + - {#if serverRows.length} - -
- {#each serverColumns as column}{/each} - {#each serverRows as server (server.server)} - - - - - {/each} -
{server.server}{#if server.currentRegistered}Open current server {/if}{metricPercent(server.active, server.observed)}{metricDuration(server.active)} of {metricDuration(server.observed)} known{metricPercent(server.idle, server.observed)}{metricDuration(server.idle)} idle{metricDuration(server.observed)}{metricDuration(server.unknown)}
- {:else}{#snippet actions()}{/snippet}{/if} - + {#if view === 'instances'}

Grouped by recorded name; links open the current instance, which may differ from the historical registration.

{/if} +
About these metrics

{view === 'maps' ? 'All lobbies full uses map time when every tracked lobby has known occupancy and capacity. It does not measure failed joins or unmet demand. A dash means unavailable, not zero.' : 'Time with players excludes unknown status and collection gaps. Rows group historical observations by name and may span multiple registrations. Links open the current instance with that name.'}

diff --git a/src/lib/ui/metrics.ts b/src/lib/ui/metrics.ts index 7f7b25c..c6ebebf 100644 --- a/src/lib/ui/metrics.ts +++ b/src/lib/ui/metrics.ts @@ -30,3 +30,12 @@ export function compareMetricValues(left: number | null, right: number | null, d if (a === null || b === null) return a === b ? 0 : a === null ? 1 : -1; return (a - b) * (direction === 'asc' ? 1 : -1); } + +/** Clamp saved pages after filtering or polling changes the result count. */ +export function metricPage(rows: T[], requested: string | null, size = 15) { + const parsed = Number(requested); + const pages = Math.max(1, Math.ceil(rows.length / size)); + const page = Math.min(pages, Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 1); + const start = (page - 1) * size; + return { page, pages, rows: rows.slice(start, start + size), start: rows.length ? start + 1 : 0, end: Math.min(rows.length, start + size), total: rows.length }; +} diff --git a/src/routes/metrics/+page.svelte b/src/routes/metrics/+page.svelte index f482e9c..edb91b1 100644 --- a/src/routes/metrics/+page.svelte +++ b/src/routes/metrics/+page.svelte @@ -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 @@ Metrics · Electric Cherry
- - {#snippet actions()}{/snippet} - +
- {format.period(report.since)} – {format.period(report.now)} -
-
+ changeMap((event.currentTarget as HTMLSelectElement).value)} /> -

{scopeNavigating ? 'Updating report…' : `${scopeLabel} · applies to every metric, chart, and table`}

+
{scopeNavigating ? 'Updating report…' : `Updated ${format.minuteTime(report.now)}`}
{#if scopeError}{/if} {#if refreshError} @@ -122,26 +127,8 @@
{/if} - {#if captured > 0} -
- - -

{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} known server time

- {#if report.summary.observed === 0}

Occupancy is unknown for all captured time.

{/if} -
- - 0 ? metricNumber(report.summary.playerHours) : '—'} size="lg" tween={false} /> -

Players × known time · one player for one hour = 1

-
-
- {/if} -
- {#if captured > 0}{metricDuration(report.summary.observed)} known server time{metricDuration(report.summary.unknown)} unknown status{/if} - Scope last collected {report.scopeLatestSampleAt === null ? 'No retained samples' : format.dateTime(report.scopeLatestSampleAt)} - Fleet last collected {report.latestSampleAt === null ? 'No retained samples' : format.dateTime(report.latestSampleAt)} - Report refreshed {format.time(report.now)} · refreshes every minute while visible - {#if report.scopeFirstSampleAt !== null}Scope retained samples from {format.dateTime(report.scopeFirstSampleAt)}{/if} -
+

{scopeLabel} · {format.period(report.since)} – {format.period(report.now)}

+ {#if captured > 0}{/if} {#if captured === 0} 0 ? 'Waiting for observed time' : 'No captured time in this scope'} @@ -165,10 +152,23 @@ {#if report.summary.observed === 0}

All captured server status is unknown. A dash means occupancy could not be calculated; it does not mean servers were empty.

{/if} {/if} - changeScope({ timeZone: value })} /> - {#if captured > 0}{/if} + {#if captured > 0} +
Activity by weekday and hour changeScope({ timeZone: value })} />
+ +
+ {#if view === 'insights'}{:else}{/if} +
+ {/if}
- How these estimates work + About this report +
+ {#if captured > 0}{metricDuration(report.summary.observed)} known server time{metricDuration(report.summary.unknown)} unknown status{/if} + Scope last collected {report.scopeLatestSampleAt === null ? 'No retained samples' : format.dateTime(report.scopeLatestSampleAt)} + Fleet last collected {report.latestSampleAt === null ? 'No retained samples' : format.dateTime(report.latestSampleAt)} + Report refreshed {format.time(report.now)} · refreshes every minute while visible + {#if report.scopeFirstSampleAt !== null}Scope retained samples from {format.dateTime(report.scopeFirstSampleAt)}{/if} +
+

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.

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.

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.

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.

@@ -178,36 +178,22 @@ diff --git a/tests/unit/ui-metrics-page.test.ts b/tests/unit/ui-metrics-page.test.ts new file mode 100644 index 0000000..b36b2ee --- /dev/null +++ b/tests/unit/ui-metrics-page.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, expect, it, vi } from 'vitest'; +import { render } from 'svelte/server'; +import type { MetricsData, MapMetrics, ServerMetrics } from '../../src/lib/metrics.js'; +import MetricsPage from '../../src/routes/metrics/+page.svelte'; +import MetricsSummary from '../../src/lib/ui/components/metrics/MetricsSummary.svelte'; +import MetricsTables from '../../src/lib/ui/components/metrics/MetricsTables.svelte'; +import MetricsExplore from '../../src/lib/ui/components/metrics/MetricsExplore.svelte'; +import MetricsChart from '../../src/lib/ui/components/metrics/MetricsChart.svelte'; +import { metricPage } from '../../src/lib/ui/metrics.js'; +import { page } from '$app/state'; + +vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/metrics'), state: {} } })); +vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), afterNavigate: vi.fn(), goto: vi.fn(), replaceState: vi.fn() })); +const map = (i: number, changes: Partial = {}): MapMetrics => ({ game: 't5', map: `Map ${String(i).padStart(2, '0')}`, observed: 10, active: 5, idle: 5, unknown: 0, playerHours: i, full: 1, capacityObserved: 4, ...changes }); +const server = (i: number): ServerMetrics => ({ server: `t5_zombie_theater_${i}`, currentRegistered: i !== 1, observed: 10, active: i / 10, idle: 10 - i / 10, unknown: 0, playerHours: i }); +function report(): MetricsData { + const now = Date.parse('2026-10-06T12:00:00Z'); + const summary = { observed: 10, active: 5, idle: 5, unknown: 2, playerHours: 20 }; + return { days: 7, since: now - 7 * 86400000, now, firstRetainedSampleAt: now - 14 * 86400000, latestSampleAt: now, + registeredServerCount: 24, scope: { game: null, map: null, timeZone: 'America/New_York' }, scopeOptions: { games: ['t5'], maps: [] }, + scopeFirstSampleAt: now - 14 * 86400000, scopeLatestSampleAt: now, summary, + maps: Array.from({ length: 24 }, (_, i) => map(i + 1)), servers: Array.from({ length: 24 }, (_, i) => server(i + 1)), + points: [{ ...summary, at: now - 86400000, startAt: now - 86400000, endAt: now, partial: true }], + previous: { since: now - 14 * 86400000, until: now - 7 * 86400000, summary: { ...summary, active: 4, idle: 6, playerHours: 10 }, firstSampleAt: now - 14 * 86400000, lastSampleAt: now - 7 * 86400000, partialHistory: true }, + pooledPressure: { value: 25, fullMapHours: 1, knownMapHours: 4, coveredMaps: 24, totalMaps: 24 }, heatmap: { timeZone: 'America/New_York', cells: [] } + }; +} +function scope(search: string, stateSearch?: string) { + Object.assign(page, { url: new URL(`http://localhost/metrics${search}`), state: stateSearch ? { metricsTableSearch: stateSearch } : {} }); +} +beforeEach(() => scope('')); + +it('shows a summary and one paginated breakdown, preserving scope on range links', () => { + scope('?game=t5&days=7&view=maps&mapPage=2'); + const body = render(MetricsPage, { props: { data: { ...report(), user: null, setupRequired: false } } }).body; + expect(body).toContain('aria-label="Metrics breakdown"'); + expect(body).toContain('aria-label="Map metrics table"'); + expect(body).not.toContain('aria-label="Instance metrics table"'); + expect(body).toContain('16–24 of 24 maps'); + expect(body).toContain('game=t5&days=30&view=maps'); + expect(body).toContain('Partial previous history'); + expect(body).toContain('About this report'); + expect(body).not.toContain('Explore this period'); +}); + +it('keeps percentage points, relative changes and unavailable capacity distinct', () => { + const data = report(); + let body = render(MetricsSummary, { props: { report: data } }).body; + expect(body).toContain('+10 pp'); expect(body).toContain('+100%'); + data.pooledPressure = { value: null, fullMapHours: 0, knownMapHours: 0, coveredMaps: 0, totalMaps: 24 }; + data.maps = [map(1, { capacityObserved: 0 })]; + data.previous.summary.playerHours = 0; + body = render(MetricsSummary, { props: { report: data } }).body; + expect(body).toContain('Capacity data unavailable'); + expect(body).toContain('New · no previous player-hours'); + expect(body).toContain('1 of 1 maps lack capacity data'); + expect(body).not.toContain('Infinity'); + data.summary.observed = 0; + body = render(MetricsSummary, { props: { report: data } }).body; + expect(body).toContain('Comparison unavailable'); +}); + +it('searches and sorts the complete map set before pagination and clamps invalid pages', () => { + const data = report(); + scope('?mapPage=2'); + let body = render(MetricsTables, { props: { maps: data.maps, servers: data.servers } }).body; + expect(body).toContain('Map 09'); expect(body).not.toContain('Map 24'); + scope('?mapPage=2', '?mapQ=Map+24&mapPage=100&mapSort=playerHours'); + body = render(MetricsTables, { props: { maps: data.maps, servers: data.servers } }).body; + expect(body).toContain('Map 24'); expect(body).toContain('1–1 of 1 maps'); + expect(body).not.toContain('>Next<'); + expect(metricPage(data.maps, '-1').page).toBe(1); + expect(metricPage(data.maps, 'Infinity').page).toBe(1); + expect(metricPage([], '20')).toMatchObject({ page: 1, start: 0, end: 0, total: 0 }); +}); + +it('keeps missing capacity last when sorting and never presents it as zero full time', () => { + scope('?mapSort=full&mapDirection=asc'); + const maps = [map(1, { capacityObserved: 0 }), map(2, { full: 0 }), map(3)]; + const body = render(MetricsTables, { props: { maps, servers: [] } }).body; + expect(body.indexOf('Map 02')).toBeLessThan(body.indexOf('Map 03')); + expect(body.indexOf('Map 03')).toBeLessThan(body.indexOf('Map 01')); + expect(body).toContain('0.0%'); expect(body).toContain('Capacity data unavailable'); +}); + +it('preserves instance return context and distinguishes historic names without current links', () => { + scope('?view=instances&serverQ=theater&days=30'); + const body = render(MetricsTables, { props: { maps: [], servers: [server(1), server(2)], view: 'instances' } }).body; + expect(body).toContain('Historical'); + expect(body).not.toContain('href="/servers/t5_zombie_theater_1'); + expect(body).toContain('href="/servers/t5_zombie_theater_2?returnTo='); + expect(body).toContain('serverQ%3Dtheater'); + expect(body).not.toContain('>Idle'); + expect(body).not.toContain('Open current server'); +}); + +it('highlights at most three supported trials and collapses tentative findings', () => { + const data = report(); data.maps.push(map(25, { observed: 0.1, active: 0.1, idle: 0, capacityObserved: 0 })); + const body = render(MetricsExplore, { props: { report: data } }).body; + const fold = body.indexOf('Other findings'); + expect(body.slice(0, fold).match(/class="recommendation /g)).toHaveLength(3); + expect(body.indexOf('Map 25')).toBeGreaterThan(fold); + expect(body).toContain('Limited evidence'); + expect(body).not.toMatch(/]*open/); + data.maps = [map(1, { capacityObserved: 0 })]; + expect(render(MetricsExplore, { props: { report: data } }).body).toContain('No hosting trials meet the evidence threshold'); +}); + +it('keeps accessible chart selection, partial periods and gaps while collapsing inspection controls', () => { + const data = report(); + data.points.push({ at: data.now, startAt: data.now, endAt: data.now + 1000, partial: false, observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 }); + const body = render(MetricsChart, { props: { points: data.points, days: data.days, timeZone: data.scope.timeZone } }).body; + expect(body).toContain('aria-label="Selected period summary"'); + expect(body).toContain('No captured time · not evidence of idle instances'); + expect(body).toContain('tabindex="0"'); + expect(body).toContain('Inspect a period'); + expect(body).toContain('Partial period'); + expect(body).not.toMatch(/]*open/); +}); + +it('uses shallow URL state for the selected breakdown while retaining the common summary', () => { + scope('?view=maps', '?view=instances&serverQ=theater&serverPage=2'); + let body = render(MetricsPage, { props: { data: { ...report(), user: null, setupRequired: false } } }).body; + expect(body).toContain('aria-label="Instance metrics table"'); + expect(body).not.toContain('aria-label="Map metrics table"'); + expect(body).toContain('16–24 of 24 instances'); + scope('?view=maps', '?view=insights'); + body = render(MetricsPage, { props: { data: { ...report(), user: null, setupRequired: false } } }).body; + expect(body).toContain('id="metrics-explore-title"'); + expect(body).toContain('Time all lobbies were full'); + expect(body).not.toContain('aria-label="Map metrics table"'); + expect(body).not.toContain('aria-label="Instance metrics table"'); +}); + +it('filters other insights without hiding the highlighted supported trials', () => { + scope('?view=insights&insightQ=Map+24&insightSort=map'); + const body = render(MetricsExplore, { props: { report: report() } }).body; + expect(body).toContain('Map 01'); + expect(body).toContain('Map 24'); + expect(body).not.toContain('Map 23'); +});