Replace instance metrics with map lobby counts (Refs #139)
CI / checks (push) Successful in 4m55s

This commit is contained in:
2026-10-10 16:40:07 -04:00
parent 616b1937ed
commit aebfc7a783
10 changed files with 278 additions and 278 deletions
+2
View File
@@ -72,6 +72,8 @@ export interface MapMetrics extends Utilization {
game: string;
map: string;
matches: MatchMetrics;
/** Concurrent lobbies over this map's captured wall time, including unknown occupancy. */
running: RunningMetrics;
/** Map wall time, not summed lobby time. Capacity does not affect availability. */
noEmpty: number;
availabilityObserved: number;
+2
View File
@@ -249,6 +249,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
map = {
game: gameName,
map: label,
running: emptyRunning(),
matches: matchMetrics({
started: 0,
withRecordedPlayers: 0,
@@ -453,6 +454,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
});
}
for (const [map, state] of availability) {
addRunning(map.running, state.lobbies, hours);
if (state.known) {
map.availabilityObserved += hours;
if (!state.empty) {
+131 -241
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import type { MapMetrics, ServerMetrics } from '$lib/metrics.js';
import type { MapMetrics } from '$lib/metrics.js';
import {
metricPage,
compareMetricValues,
@@ -9,35 +9,22 @@
metricNumber,
metricPercent
} from '$ui/metrics.js';
import { serverTitle } from '$ui/server-labels.js';
import { contextHref } from '$lib/navigation-context.js';
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
let {
maps,
servers,
view = 'maps'
}: { maps: MapMetrics[]; servers: ServerMetrics[]; view?: 'maps' | 'instances' } = $props();
let { maps }: { maps: MapMetrics[] } = $props();
const mapColumns = [
{ key: 'map', label: 'Map' },
{ key: 'matches', label: 'Matches' },
{ key: 'playerHours', label: 'Player-hours' },
{ key: 'active', label: 'With players' },
{ key: 'lobbies', label: 'Lobbies (avg · peak)' },
{ key: 'noEmpty', label: 'No empty lobby' },
{ key: 'details', label: 'Details' }
] as const;
const serverColumns = [
{ key: 'server', label: 'Instance' },
{ key: 'utilization', label: 'Time with players' },
{ key: 'observed', label: 'Observed time' },
{ key: 'unknown', label: 'Unknown time' },
{ key: 'details', label: 'Details' }
] as const;
type MapSort = (typeof mapColumns)[number]['key'];
type ServerSort = (typeof serverColumns)[number]['key'];
type Direction = 'asc' | 'desc';
// Shallow navigation changes page.state, not page.url. Store the working
// search string there so filters react immediately and survive Back/Forward.
@@ -46,21 +33,12 @@
);
const params = $derived(new URLSearchParams(contextSearch));
const mapQuery = $derived((params.get('mapQ') ?? '').slice(0, 100));
const serverQuery = $derived((params.get('serverQ') ?? '').slice(0, 100));
const mapSort = $derived(
mapColumns.find(
(column) => column.key !== 'details' && column.key === params.get('mapSort')
)?.key ?? 'playerHours'
);
const serverSort = $derived(
serverColumns.find(
(column) => column.key !== 'details' && column.key === params.get('serverSort')
)?.key ?? 'utilization'
);
const mapDirection = $derived<Direction>(params.get('mapDirection') === 'asc' ? 'asc' : 'desc');
const serverDirection = $derived<Direction>(
params.get('serverDirection') === 'asc' ? 'asc' : 'desc'
);
const maxPlayerHours = $derived(Math.max(0, ...maps.map((map) => map.playerHours)));
const mapRows = $derived(
maps
@@ -81,47 +59,18 @@
return result || a.map.localeCompare(b.map) || a.game.localeCompare(b.game);
})
);
const serverRows = $derived(
servers
.filter((server) =>
`${server.server} ${serverTitle(server.server)}`
.toLowerCase()
.includes(serverQuery.trim().toLowerCase())
)
.sort((a, b) => {
const result =
serverSort === 'server'
? serverTitle(a.server).localeCompare(serverTitle(b.server)) *
(serverDirection === 'asc' ? 1 : -1)
: compareMetricValues(
serverValue(a, serverSort),
serverValue(b, serverSort),
serverDirection
);
return result || b.observed - a.observed || a.server.localeCompare(b.server);
})
);
const mapPage = $derived(metricPage(mapRows, params.get('mapPage')));
const serverPage = $derived(metricPage(serverRows, params.get('serverPage')));
const currentPage = $derived(view === 'maps' ? mapPage : serverPage);
const currentQuery = $derived(view === 'maps' ? mapQuery : serverQuery);
const pageKey = $derived(view === 'maps' ? 'mapPage' : 'serverPage');
function gameName(game: string) {
return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
}
function mapValue(map: MapMetrics, key: Exclude<MapSort, 'map'>): number | null {
if (key === 'details') return null;
if (key === 'matches') return map.matches.started;
if (key === 'lobbies') return map.running.average;
if (key === 'noEmpty')
return map.availabilityObserved ? map.noEmpty / map.availabilityObserved : null;
return (key === 'playerHours' || key === 'active') && !map.observed ? null : map[key];
}
function serverValue(server: ServerMetrics, key: Exclude<ServerSort, 'server'>): number | null {
if (key === 'details') return null;
if (key === 'utilization')
return server.observed > 0 ? server.active / server.observed : null;
return server[key];
}
function updateContext(changes: Record<string, string>) {
const url = new URL(page.url);
url.search = contextSearch;
@@ -130,7 +79,6 @@
else url.searchParams.delete(key);
}
if ('mapQ' in changes || 'mapSort' in changes) url.searchParams.delete('mapPage');
if ('serverQ' in changes || 'serverSort' in changes) url.searchParams.delete('serverPage');
replaceState(url, { ...page.state, metricsTableSearch: url.search });
}
function sortMap(key: MapSort) {
@@ -146,233 +94,178 @@
: 'desc'
});
}
function sortServer(key: ServerSort) {
updateContext({
serverSort: key,
serverDirection:
key === serverSort
? serverDirection === 'asc'
? 'desc'
: 'asc'
: key === 'server'
? 'asc'
: 'desc'
});
}
</script>
<section class="panel" aria-label={view === 'maps' ? 'Map metrics' : 'Instance metrics'}>
<section class="panel" aria-label="Map metrics">
<div class="heading">
<h2>{view === 'maps' ? 'Map play time and lobby availability' : 'Instance utilization'}</h2>
<h2>Map play time and lobby availability</h2>
<div class="search">
<Field label={view === 'maps' ? 'Find a map' : 'Find an instance'}
<Field label="Find a map"
><Input
type="search"
value={view === 'maps' ? mapQuery : serverQuery}
placeholder={view === 'maps' ? 'Map or game…' : 'Instance name or map…'}
value={mapQuery}
placeholder="Map or game…"
oninput={(event) =>
updateContext({
[view === 'maps' ? 'mapQ' : 'serverQ']: (
event.currentTarget as HTMLInputElement
).value
})}
updateContext({ mapQ: (event.currentTarget as HTMLInputElement).value })}
/></Field
>
</div>
</div>
{#if currentPage.total}
{#if mapPage.total}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
<div
class="table-wrap"
role="region"
aria-label={view === 'maps' ? 'Map metrics table' : 'Instance metrics table'}
tabindex="0"
>
<div class="table-wrap" role="region" aria-label="Map metrics table" tabindex="0">
<table>
<thead
><tr
>{#each view === 'maps' ? mapColumns : serverColumns as column}
><tr>
{#each mapColumns as column}
<th
scope="col"
aria-sort={column.key === 'details'
? undefined
: (view === 'maps' ? mapSort : serverSort) === column.key
? (view === 'maps' ? mapDirection : serverDirection) === 'asc'
: mapSort === column.key
? mapDirection === 'asc'
? 'ascending'
: 'descending'
: undefined}
>
{#if column.key === 'details'}Details{:else}<button
type="button"
onclick={() =>
view === 'maps'
? sortMap(column.key as MapSort)
: sortServer(column.key as ServerSort)}
>{column.label}<span aria-hidden="true"
>{(view === 'maps' ? mapSort : serverSort) ===
column.key
? (view === 'maps'
? mapDirection
: serverDirection) === 'asc'
onclick={() => sortMap(column.key)}
>
{column.label}<span aria-hidden="true"
>{mapSort === column.key
? mapDirection === 'asc'
? '↑'
: '↓'
: '↕'}</span
></button
>{/if}
>
</button>{/if}
</th>
{/each}</tr
></thead
{/each}
</tr></thead
>
<tbody>
{#if view === 'maps'}
{#each mapPage.rows as map (JSON.stringify([map.game, map.map]))}<tr>
<th scope="row"
>{map.map}<small
>{gameName(map.game)}{map.observed < 1 ||
map.availabilityObserved < 1 ||
map.unknown > map.observed / 4 ||
map.availabilityUnknown > map.availabilityObserved / 4
? ' · Limited evidence'
: ''}</small
></th
{#each mapPage.rows as map (JSON.stringify([map.game, map.map]))}<tr>
<th scope="row"
>{map.map}<small
>{gameName(map.game)}{map.observed < 1 ||
map.availabilityObserved < 1 ||
map.unknown > map.observed / 4 ||
map.availabilityUnknown > map.availabilityObserved / 4
? ' · Limited evidence'
: ''}</small
></th
>
<td
>{metricNumber(map.matches.started)}<small
>{map.matches.averageGroupSize === null
? 'Average group unavailable'
: `avg group ${metricNumber(map.matches.averageGroupSize)}`}</small
></td
>
<td class="player-hours"
>{map.observed ? metricNumber(map.playerHours) : '—'}
<div class="comparison" aria-hidden="true">
<i
style:width={`${maxPlayerHours ? (map.playerHours / maxPlayerHours) * 100 : 0}%`}
></i>
</div></td
>
<td>{map.observed ? metricDuration(map.active) : '—'}</td>
<td class="lobbies">
{map.running.average === null
? '—'
: `${metricNumber(map.running.average)} · ${metricNumber(map.running.peak)}`}
<small
>{map.observed
? `${metricDuration(map.idle)} empty`
: 'Empty time unavailable'}</small
>
<td
>{metricNumber(map.matches.started)}<small
>{map.matches.averageGroupSize === null
? 'Average group unavailable'
: `avg group ${metricNumber(map.matches.averageGroupSize)}`}</small
></td
>
<td class="player-hours"
>{map.observed ? metricNumber(map.playerHours) : '—'}
<div class="comparison" aria-hidden="true">
<i
style:width={`${maxPlayerHours ? (map.playerHours / maxPlayerHours) * 100 : 0}%`}
></i>
</div></td
>
<td>{map.observed ? metricDuration(map.active) : '—'}</td>
<td>{metricPercent(map.noEmpty, map.availabilityObserved)}</td>
<td
><details>
<summary
><span class="sr-only">{map.map} </span>Details</summary
>
<dl>
<dt>Matches with recorded players</dt>
<dd>
{metricNumber(map.matches.withRecordedPlayers)} of {metricNumber(
map.matches.started
)}
</dd>
<dt>Solo matches</dt>
<dd>
{map.matches.soloShare === null
? 'Unavailable · no recorded players'
: `${metricNumber(map.matches.solo)} · ${metricPercent(map.matches.soloShare, 100)} of matches with recorded players`}
</dd>
<dt>Observed lobby time</dt>
<dd>{metricDuration(map.observed)}</dd>
<dt>Unknown status</dt>
<dd>{metricDuration(map.unknown)}</dd>
<dt>No empty lobby</dt>
<dd>
{map.availabilityObserved
? `${metricDuration(map.noEmpty)} of ${metricDuration(map.availabilityObserved)} known map time`
: 'Lobby availability unavailable'}
</dd>
<dt>Unknown map availability</dt>
<dd>{metricDuration(map.availabilityUnknown)}</dd>
</dl>
</details></td
>
</tr>{/each}
{:else}
{#each serverPage.rows as server (server.server)}<tr>
<th scope="row"
>{#if server.currentRegistered}<a
href={contextHref(
`/servers/${encodeURIComponent(server.server)}`,
`/metrics${contextSearch}`
</td>
<td>{metricPercent(map.noEmpty, map.availabilityObserved)}</td>
<td
><details>
<summary><span class="sr-only">{map.map} </span>Details</summary
>
<dl>
<dt>Matches with recorded players</dt>
<dd>
{metricNumber(map.matches.withRecordedPlayers)} of {metricNumber(
map.matches.started
)}
title="Open current instance with this name"
>{serverTitle(server.server)}</a
>{:else}{serverTitle(server.server)}<small>Historical</small
>{/if}</th
>
<td>{metricPercent(server.active, server.observed)}</td><td
>{metricDuration(server.observed)}</td
><td>{metricDuration(server.unknown)}</td>
<td
><details>
<summary
><span class="sr-only"
>{serverTitle(server.server)}
</span>Details</summary
>
<dl>
<dt>Recorded name</dt>
<dd><code>{server.server}</code></dd>
<dt>Time with players</dt>
<dd>{metricDuration(server.active)}</dd>
<dt>Empty spare</dt>
<dd>
{metricPercent(server.idle, server.observed)} · {metricDuration(
server.idle
)}
</dd>
</dl>
</details></td
>
</tr>{/each}
{/if}
</dd>
<dt>Solo matches</dt>
<dd>
{map.matches.soloShare === null
? 'Unavailable · no recorded players'
: `${metricNumber(map.matches.solo)} · ${metricPercent(map.matches.soloShare, 100)} of matches with recorded players`}
</dd>
<dt>Captured map time</dt>
<dd>{metricDuration(map.running.capturedHours)}</dd>
<dt>Empty lobby-hours</dt>
<dd>
{map.observed
? metricDuration(map.idle)
: 'Unavailable · no known occupancy'}
</dd>
<dt>Observed lobby time</dt>
<dd>{metricDuration(map.observed)}</dd>
<dt>Unknown status</dt>
<dd>{metricDuration(map.unknown)}</dd>
<dt>No empty lobby</dt>
<dd>
{map.availabilityObserved
? `${metricDuration(map.noEmpty)} of ${metricDuration(map.availabilityObserved)} known map time`
: 'Lobby availability unavailable'}
</dd>
<dt>Unknown map availability</dt>
<dd>{metricDuration(map.availabilityUnknown)}</dd>
</dl>
</details></td
>
</tr>{/each}
</tbody>
</table>
</div>
{:else}<EmptyState
title={currentQuery.trim()
? view === 'maps'
? 'No maps match this search'
: 'No instances match this search'
: view === 'maps'
? 'No map history in this window'
: 'No instance observations in this window'}
>{#snippet actions()}{#if currentQuery.trim()}<Button
size="sm"
onclick={() =>
updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: '' })}
>Clear search</Button
>{/if}{/snippet}</EmptyState
>{/if}
<div class="footer">
<span
>{currentPage.start}–{currentPage.end} of {currentPage.total}
{view === 'maps' ? 'maps' : 'instances'}</span
title={mapQuery.trim() ? 'No maps match this search' : 'No map history in this window'}
>
{#if currentPage.pages > 1}<div class="paging">
{#snippet actions()}{#if mapQuery.trim()}<Button
size="sm"
onclick={() => updateContext({ mapQ: '' })}>Clear search</Button
>{/if}{/snippet}
</EmptyState>{/if}
<div class="footer">
<span>{mapPage.start}–{mapPage.end} of {mapPage.total} maps</span>
{#if mapPage.pages > 1}<div class="paging">
<Button
size="sm"
disabled={currentPage.page === 1}
onclick={() => updateContext({ [pageKey]: String(currentPage.page - 1) })}
disabled={mapPage.page === 1}
onclick={() => updateContext({ mapPage: String(mapPage.page - 1) })}
>Previous</Button
><span>Page {currentPage.page} of {currentPage.pages}</span><Button
>
<span>Page {mapPage.page} of {mapPage.pages}</span>
<Button
size="sm"
disabled={currentPage.page === currentPage.pages}
onclick={() => updateContext({ [pageKey]: String(currentPage.page + 1) })}
disabled={mapPage.page === mapPage.pages}
onclick={() => updateContext({ mapPage: String(mapPage.page + 1) })}
>Next</Button
>
</div>{/if}
</div>
{#if view === 'instances'}<p class="identity-note">
Grouped by recorded name; links open the current instance, which may differ from the
historical registration.
</p>{/if}
<details class="table-help">
<summary>About these metrics</summary>
<p>
{view === 'maps'
? 'Matches counts retained matches started in this window, including ongoing and interrupted matches. Average group counts distinct recorded players across each match’s lifetime and excludes matches with no recorded players. No empty lobby measures map time when at least one tracked lobby has known occupancy and none report zero players. If every lobby is unknown, map availability is unknown. Player limits are not required. This 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.'}
Matches counts retained matches started in this window, including ongoing and
interrupted matches. Average group counts distinct recorded players across each match’s
lifetime and excludes matches with no recorded players. Lobbies shows average and peak
concurrent counts over each map’s captured wall time, including unknown occupancy;
sorting uses the average. Collection gaps and periods without recorded lobbies for that
map are excluded. Empty lobby-hours sum time with zero reported players across the map’s
lobbies. Unknown occupancy is not counted as empty. No empty lobby measures map time
when at least one tracked lobby has known occupancy and none report zero players. If
every lobby is unknown, map availability is unknown. Player limits are not required.
This does not measure failed joins or unmet demand. A dash means unavailable, not zero.
</p>
</details>
</section>
@@ -456,6 +349,9 @@
font-size: var(--text-xs);
color: var(--text-secondary);
}
.lobbies {
white-space: nowrap;
}
.comparison {
height: 3px;
width: 90px;
@@ -502,12 +398,6 @@
color: var(--text-primary);
overflow-wrap: anywhere;
}
.identity-note {
margin: 0;
padding: 0 20px 12px;
color: var(--text-secondary);
font-size: var(--text-xs);
}
.table-help {
padding: 0 20px 16px;
}
+1
View File
@@ -93,6 +93,7 @@ function validReport(value: unknown): value is MetricsData {
record(item) &&
totals(item) &&
matches(item.matches) &&
running(item.running) &&
typeof item.game === 'string' &&
typeof item.map === 'string' &&
[
+5 -11
View File
@@ -40,11 +40,7 @@
(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'
new URLSearchParams(contextSearch).get('view') === 'insights' ? 'insights' : 'maps'
);
function changeView(value: string) {
const url = new URL(page.url);
@@ -317,7 +313,6 @@
<Tabs
tabs={[
{ id: 'maps', label: 'Maps' },
{ id: 'instances', label: 'Instances' },
{ id: 'insights', label: 'Autoscaling signals' }
]}
value={view}
@@ -329,8 +324,6 @@
<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}
@@ -413,11 +406,12 @@
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.
Instance time with players uses known lobby 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.
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>