Replace instance metrics with map lobby counts (Refs #139)
CI / checks (push) Successful in 4m55s
CI / checks (push) Successful in 4m55s
This commit is contained in:
+12
-5
@@ -73,16 +73,23 @@ the same figures for comparison and assistive technology. Zero captured time is
|
||||
distinct from an observed empty lobby. Short durations use seconds or minutes so
|
||||
a real observation is not rounded to zero.
|
||||
|
||||
Map and instance tables support numeric sorting and local search. The map table's
|
||||
The map table supports numeric sorting and local search. **Lobbies (avg · peak)**
|
||||
shows the time-weighted average and peak concurrent lobby count for each recorded
|
||||
game/map pair, with empty lobby-hours beneath. Sort this column by average count.
|
||||
The average divides all captured lobby-hours, including unknown occupancy, by
|
||||
that map's captured wall time; collection gaps and periods without recorded lobbies
|
||||
for that map are excluded. Peak is the largest captured concurrent count, not the
|
||||
number of distinct lobby names. Empty lobby-hours sum time with zero reported
|
||||
players across the map's lobbies. Unknown occupancy is not counted as empty;
|
||||
empty time is unavailable when no occupancy is known. Lobby counts are unavailable
|
||||
without captured time. Per-lobby match and activity history is available on server pages.
|
||||
|
||||
The map table's
|
||||
**No empty lobby** column uses occupancy alone and retains the known map-time
|
||||
denominator in its details. Unknown map availability stays distinct from zero
|
||||
time without a spare. Evidence can be limited by short observations or unknown
|
||||
occupancy, never by missing player limits.
|
||||
|
||||
Instance rows describe history grouped by server name, which can include different
|
||||
instances if a name was reused. **Open current server** appears only when that name
|
||||
is currently registered; its destination represents the current registration.
|
||||
|
||||
## Comparisons and activity
|
||||
|
||||
Previous-period comparisons use the preceding equally long window with identical
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' &&
|
||||
[
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -13,6 +13,7 @@ const policy = {
|
||||
const map = (values: Partial<MapMetrics> = {}): MapMetrics => ({
|
||||
game: 't5',
|
||||
map: 'Moon',
|
||||
running: { average: 2, peak: 2, capturedHours: 5 },
|
||||
matches: {
|
||||
started: 0,
|
||||
withRecordedPlayers: 0,
|
||||
|
||||
@@ -155,6 +155,7 @@ describe('metrics background refresh', () => {
|
||||
game: 't5',
|
||||
map: 'Moon',
|
||||
matches,
|
||||
running: { average: null, peak: 0, capturedHours: 0 },
|
||||
noEmpty: 0,
|
||||
availabilityObserved: 0,
|
||||
availabilityUnknown: 0,
|
||||
@@ -205,6 +206,7 @@ describe('metrics background refresh', () => {
|
||||
game: 't5',
|
||||
map: 'Moon',
|
||||
matches: emptyMatches,
|
||||
running: { average: 1, peak: 1, capturedHours: 1 },
|
||||
noEmpty: 0,
|
||||
availabilityObserved: 0,
|
||||
availabilityUnknown: 1,
|
||||
@@ -232,6 +234,19 @@ describe('metrics background refresh', () => {
|
||||
]
|
||||
};
|
||||
const invalid = [
|
||||
{ ...value, maps: [{ ...value.maps[0], running: undefined }] },
|
||||
{
|
||||
...value,
|
||||
maps: [{ ...value.maps[0], running: { average: -1, peak: 1, capturedHours: 1 } }]
|
||||
},
|
||||
{
|
||||
...value,
|
||||
maps: [{ ...value.maps[0], running: { average: 1, peak: '1', capturedHours: 1 } }]
|
||||
},
|
||||
{
|
||||
...value,
|
||||
maps: [{ ...value.maps[0], running: { average: 1, peak: 1, capturedHours: null } }]
|
||||
},
|
||||
{ ...value, autoscalingPaused: undefined },
|
||||
{ ...value, running: undefined },
|
||||
{ ...value, previous: { ...value.previous, running: undefined } },
|
||||
|
||||
@@ -77,6 +77,7 @@ describe('historical metrics', () => {
|
||||
});
|
||||
expect(result.maps).toHaveLength(1);
|
||||
expect(result.maps[0].matches).toEqual(result.matches);
|
||||
expect(result.maps[0].running).toEqual({ average: null, peak: 0, capturedHours: 0 });
|
||||
expect(result.registeredServerCount).toBe(0);
|
||||
expect(result.servers).toEqual([]);
|
||||
expect(result.scopeOptions.maps).not.toContainEqual({ game: 't5', map: 'Future Map' });
|
||||
@@ -365,6 +366,8 @@ describe('historical metrics', () => {
|
||||
expect(later.maps[0].availabilityUnknown * 3600).toBeCloseTo(90);
|
||||
expect(later.pooledAvailability.value).toBeNull();
|
||||
expect(initial.running).toEqual({ average: null, peak: 0, capturedHours: 0 });
|
||||
expect(initial.maps[0].running).toEqual(initial.running);
|
||||
expect(later.maps[0].running).toEqual({ average: 1, peak: 1, capturedHours: 90 / 3600 });
|
||||
});
|
||||
|
||||
it('returns clipped interval boundaries and marks only partial UTC buckets', async () => {
|
||||
@@ -597,6 +600,7 @@ describe('historical metrics', () => {
|
||||
expect(result.running.average).toBeCloseTo(1.6);
|
||||
expect(result.running.peak).toBe(3);
|
||||
expect(result.running.capturedHours * 3600).toBeCloseTo(150);
|
||||
expect(result.maps[0].running).toEqual(result.running);
|
||||
expect(result.previous.running.average).toBe(4);
|
||||
expect(result.previous.running.peak).toBe(4);
|
||||
expect(result.previous.running.capturedHours * 3600).toBeCloseTo(90);
|
||||
@@ -610,6 +614,50 @@ describe('historical metrics', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('counts concurrent lobbies per recorded game/map across reused names, clipped intervals and collection gaps', async () => {
|
||||
const { recordMetrics, readMetrics } =
|
||||
await import('../../src/lib/server/fleet/metrics.js');
|
||||
const now = Date.UTC(2026, 0, 3, 12);
|
||||
const since = now - 86400_000;
|
||||
const one = { server: 'one', game: 't5', map: 'Moon', players: 0, capacity: null };
|
||||
const two = { ...one, server: 'two', players: null };
|
||||
const otherGame = { ...one, server: 't6-one', game: 't6' };
|
||||
recordMetrics([one, two, otherGame], since - 30_000);
|
||||
recordMetrics([{ ...one, server: 'three', players: 2 }], since + 15_000);
|
||||
recordMetrics(
|
||||
[
|
||||
otherGame,
|
||||
{ ...otherGame, server: 't6-two' },
|
||||
{ ...otherGame, server: 't6-three' },
|
||||
{ ...one, map: 'Kino Der Toten' }
|
||||
],
|
||||
since + 45_000
|
||||
);
|
||||
// Both lobby names are reused after the map was absent from a collection.
|
||||
recordMetrics([one, { ...two, players: 0 }], now - 30_000);
|
||||
// A snapshot with no elapsed time must not inflate the captured peak.
|
||||
recordMetrics([one, two, { ...one, server: 'three' }], now);
|
||||
const result = readMetrics(1, now);
|
||||
const moon = result.maps.find((map) => map.game === 't5' && map.map === 'Moon')!;
|
||||
expect(moon.running.average).toBeCloseTo(1.6);
|
||||
expect(moon.running.peak).toBe(2);
|
||||
expect(moon.running.capturedHours * 3600).toBeCloseTo(75);
|
||||
expect(moon.idle * 3600).toBeCloseTo(75);
|
||||
expect(moon.unknown * 3600).toBeCloseTo(15);
|
||||
expect(moon.observed + moon.unknown).toBeCloseTo(
|
||||
moon.running.average! * moon.running.capturedHours
|
||||
);
|
||||
const t6 = result.maps.find((map) => map.game === 't6')!;
|
||||
expect(t6.running.average).toBeCloseTo(285 / 105);
|
||||
expect(t6.running.peak).toBe(3);
|
||||
expect(t6.running.capturedHours * 3600).toBeCloseTo(105);
|
||||
const kino = result.maps.find((map) => map.map === 'Kino Der Toten')!;
|
||||
expect(kino.running).toEqual({ average: 1, peak: 1, capturedHours: 90 / 3600 });
|
||||
const scoped = readMetrics({ days: 1, game: 't5', map: 'Moon', timeZone: 'UTC' }, now);
|
||||
expect(scoped.maps).toEqual([moon]);
|
||||
expect(scoped.running).toEqual(moon.running);
|
||||
});
|
||||
|
||||
it('matches current policy context through map aliases and prefers the durable owner', async () => {
|
||||
const { recordMetrics, readMetrics } =
|
||||
await import('../../src/lib/server/fleet/metrics.js');
|
||||
|
||||
@@ -30,6 +30,7 @@ const matches = (changes: Partial<MatchMetrics> = {}): MatchMetrics => ({
|
||||
...changes
|
||||
});
|
||||
const map = (i: number, changes: Partial<MapMetrics> = {}): MapMetrics => ({
|
||||
running: { average: 2.5, peak: 3, capturedHours: 4 },
|
||||
game: 't5',
|
||||
map: `Map ${String(i).padStart(2, '0')}`,
|
||||
matches: matches({
|
||||
@@ -189,11 +190,11 @@ it('shows concurrent lobbies and empty spare share with unknown lobby-hours in t
|
||||
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;
|
||||
let body = render(MetricsTables, { props: { maps: data.maps } }).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;
|
||||
body = render(MetricsTables, { props: { maps: data.maps } }).body;
|
||||
expect(body).toContain('Map 24');
|
||||
expect(body.replace(/\s+/g, ' ')).toContain('1–1 of 1 maps');
|
||||
expect(body).not.toContain('>Next<');
|
||||
@@ -245,7 +246,7 @@ it('sorts map match counts numerically, including maps without occupancy or reco
|
||||
map(4, { matches: { ...noRoster, started: 0 } })
|
||||
];
|
||||
scope('?mapSort=matches&mapDirection=asc');
|
||||
let body = render(MetricsTables, { props: { maps, servers: [] } }).body;
|
||||
let body = render(MetricsTables, { props: { maps } }).body;
|
||||
expect(body.indexOf('Map 04')).toBeLessThan(body.indexOf('Map 02'));
|
||||
expect(body.indexOf('Map 02')).toBeLessThan(body.indexOf('Map 03'));
|
||||
expect(body.indexOf('Map 03')).toBeLessThan(body.indexOf('Map 01'));
|
||||
@@ -255,7 +256,7 @@ it('sorts map match counts numerically, including maps without occupancy or reco
|
||||
expect(body.replace(/\s+/g, ' ')).toContain('0 of 100');
|
||||
|
||||
scope('?mapSort=matches&mapDirection=desc');
|
||||
body = render(MetricsTables, { props: { maps, servers: [] } }).body;
|
||||
body = render(MetricsTables, { props: { maps } }).body;
|
||||
expect(body.indexOf('Map 01')).toBeLessThan(body.indexOf('Map 03'));
|
||||
expect(body.indexOf('Map 03')).toBeLessThan(body.indexOf('Map 02'));
|
||||
expect(body.indexOf('Map 02')).toBeLessThan(body.indexOf('Map 04'));
|
||||
@@ -274,6 +275,7 @@ it('keeps match summaries and map rows visible without any retained occupancy sa
|
||||
data.maps = [
|
||||
map(1, {
|
||||
...data.summary,
|
||||
running: data.running,
|
||||
noEmpty: 0,
|
||||
availabilityObserved: 0,
|
||||
matches: data.matches
|
||||
@@ -302,7 +304,7 @@ it('keeps unknown availability last when sorting and distinguishes it from known
|
||||
map(2, { noEmpty: 0 }),
|
||||
map(3)
|
||||
];
|
||||
const body = render(MetricsTables, { props: { maps, servers: [] } }).body;
|
||||
const body = render(MetricsTables, { props: { maps } }).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%');
|
||||
@@ -311,18 +313,56 @@ it('keeps unknown availability last when sorting and distinguishes it from known
|
||||
expect(body).not.toContain('All lobbies full');
|
||||
});
|
||||
|
||||
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</button>');
|
||||
expect(body).not.toContain('Open current server');
|
||||
});
|
||||
it.each(['', '?view=instances&serverQ=theater&days=30'])(
|
||||
'shows map lobby metrics without an Instances tab for %s',
|
||||
(search) => {
|
||||
scope(search);
|
||||
const body = render(MetricsPage, {
|
||||
props: { data: { ...report(), user: null, setupRequired: false } }
|
||||
}).body;
|
||||
expect(body).toContain('aria-label="Map metrics table"');
|
||||
expect(body).toContain('aria-labelledby="metrics-views-maps"');
|
||||
expect(body).toContain('Lobbies (avg · peak)');
|
||||
expect(body).not.toContain('Instances');
|
||||
expect(body).not.toContain('Instance utilization');
|
||||
expect(body).not.toContain('Historical');
|
||||
expect(body).not.toContain('grouped by name');
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['asc', 'desc'])(
|
||||
'sorts average lobby counts numerically %s with missing evidence last and shows empty time',
|
||||
(direction) => {
|
||||
scope(`?mapSort=lobbies&mapDirection=${direction}`);
|
||||
const maps = [
|
||||
map(1, { running: { average: null, peak: 0, capturedHours: 0 }, observed: 0, idle: 0 }),
|
||||
map(2, { running: { average: 2.5, peak: 4, capturedHours: 2 }, idle: 0 }),
|
||||
map(3, { running: { average: 10, peak: 12, capturedHours: 1 }, idle: 1.2 }),
|
||||
map(4, {
|
||||
running: { average: 3, peak: 3, capturedHours: 1 },
|
||||
observed: 0,
|
||||
idle: 0,
|
||||
unknown: 3
|
||||
})
|
||||
];
|
||||
const body = render(MetricsTables, { props: { maps } }).body;
|
||||
const ordered = direction === 'asc' ? [2, 4, 3, 1] : [3, 4, 2, 1];
|
||||
for (let i = 1; i < ordered.length; i++) {
|
||||
expect(body.indexOf(`Map 0${ordered[i - 1]}`)).toBeLessThan(
|
||||
body.indexOf(`Map 0${ordered[i]}`)
|
||||
);
|
||||
}
|
||||
expect(body).toContain(`aria-sort="${direction === 'asc' ? 'ascending' : 'descending'}"`);
|
||||
expect(body).toContain('2.5 · 4');
|
||||
expect(body).toContain('10 · 12');
|
||||
expect(body).toContain('3 · 3');
|
||||
expect(body).toContain('0 h empty');
|
||||
expect(body).toContain('1.2 h empty');
|
||||
expect(body).toContain('Empty time unavailable');
|
||||
expect(body).toContain('Captured map time');
|
||||
expect(body).toMatch(/class="lobbies [^"]*">—\s*<small/);
|
||||
}
|
||||
);
|
||||
|
||||
it('highlights at most three supported policy changes and collapses tentative findings', () => {
|
||||
const data = report();
|
||||
@@ -475,13 +515,13 @@ it('renders whole-lobby axis ticks without rounding fractional averages or other
|
||||
});
|
||||
|
||||
it('uses shallow URL state for the selected breakdown while retaining the common summary', () => {
|
||||
scope('?view=maps', '?view=instances&serverQ=theater&serverPage=2');
|
||||
scope('?view=insights', '?view=maps&mapPage=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.replace(/\s+/g, ' ')).toContain('16–24 of 24 instances');
|
||||
expect(body).toContain('aria-label="Map metrics table"');
|
||||
expect(body).not.toContain('id="metrics-explore-title"');
|
||||
expect(body.replace(/\s+/g, ' ')).toContain('16–24 of 24 maps');
|
||||
scope('?view=maps', '?view=insights');
|
||||
body = render(MetricsPage, {
|
||||
props: { data: { ...report(), user: null, setupRequired: false } }
|
||||
|
||||
Reference in New Issue
Block a user