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
|
distinct from an observed empty lobby. Short durations use seconds or minutes so
|
||||||
a real observation is not rounded to zero.
|
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
|
**No empty lobby** column uses occupancy alone and retains the known map-time
|
||||||
denominator in its details. Unknown map availability stays distinct from zero
|
denominator in its details. Unknown map availability stays distinct from zero
|
||||||
time without a spare. Evidence can be limited by short observations or unknown
|
time without a spare. Evidence can be limited by short observations or unknown
|
||||||
occupancy, never by missing player limits.
|
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
|
## Comparisons and activity
|
||||||
|
|
||||||
Previous-period comparisons use the preceding equally long window with identical
|
Previous-period comparisons use the preceding equally long window with identical
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export interface MapMetrics extends Utilization {
|
|||||||
game: string;
|
game: string;
|
||||||
map: string;
|
map: string;
|
||||||
matches: MatchMetrics;
|
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. */
|
/** Map wall time, not summed lobby time. Capacity does not affect availability. */
|
||||||
noEmpty: number;
|
noEmpty: number;
|
||||||
availabilityObserved: number;
|
availabilityObserved: number;
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
|||||||
map = {
|
map = {
|
||||||
game: gameName,
|
game: gameName,
|
||||||
map: label,
|
map: label,
|
||||||
|
running: emptyRunning(),
|
||||||
matches: matchMetrics({
|
matches: matchMetrics({
|
||||||
started: 0,
|
started: 0,
|
||||||
withRecordedPlayers: 0,
|
withRecordedPlayers: 0,
|
||||||
@@ -453,6 +454,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const [map, state] of availability) {
|
for (const [map, state] of availability) {
|
||||||
|
addRunning(map.running, state.lobbies, hours);
|
||||||
if (state.known) {
|
if (state.known) {
|
||||||
map.availabilityObserved += hours;
|
map.availabilityObserved += hours;
|
||||||
if (!state.empty) {
|
if (!state.empty) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { replaceState } from '$app/navigation';
|
import { replaceState } from '$app/navigation';
|
||||||
import type { MapMetrics, ServerMetrics } from '$lib/metrics.js';
|
import type { MapMetrics } from '$lib/metrics.js';
|
||||||
import {
|
import {
|
||||||
metricPage,
|
metricPage,
|
||||||
compareMetricValues,
|
compareMetricValues,
|
||||||
@@ -9,35 +9,22 @@
|
|||||||
metricNumber,
|
metricNumber,
|
||||||
metricPercent
|
metricPercent
|
||||||
} from '$ui/metrics.js';
|
} 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 Input from '$ui/components/primitives/Input.svelte';
|
||||||
import Field from '$ui/components/primitives/Field.svelte';
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||||
|
|
||||||
let {
|
let { maps }: { maps: MapMetrics[] } = $props();
|
||||||
maps,
|
|
||||||
servers,
|
|
||||||
view = 'maps'
|
|
||||||
}: { maps: MapMetrics[]; servers: ServerMetrics[]; view?: 'maps' | 'instances' } = $props();
|
|
||||||
const mapColumns = [
|
const mapColumns = [
|
||||||
{ key: 'map', label: 'Map' },
|
{ key: 'map', label: 'Map' },
|
||||||
{ key: 'matches', label: 'Matches' },
|
{ key: 'matches', label: 'Matches' },
|
||||||
{ key: 'playerHours', label: 'Player-hours' },
|
{ key: 'playerHours', label: 'Player-hours' },
|
||||||
{ key: 'active', label: 'With players' },
|
{ key: 'active', label: 'With players' },
|
||||||
|
{ key: 'lobbies', label: 'Lobbies (avg · peak)' },
|
||||||
{ key: 'noEmpty', label: 'No empty lobby' },
|
{ key: 'noEmpty', label: 'No empty lobby' },
|
||||||
{ key: 'details', label: 'Details' }
|
{ key: 'details', label: 'Details' }
|
||||||
] as const;
|
] 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 MapSort = (typeof mapColumns)[number]['key'];
|
||||||
type ServerSort = (typeof serverColumns)[number]['key'];
|
|
||||||
type Direction = 'asc' | 'desc';
|
type Direction = 'asc' | 'desc';
|
||||||
// Shallow navigation changes page.state, not page.url. Store the working
|
// Shallow navigation changes page.state, not page.url. Store the working
|
||||||
// search string there so filters react immediately and survive Back/Forward.
|
// search string there so filters react immediately and survive Back/Forward.
|
||||||
@@ -46,21 +33,12 @@
|
|||||||
);
|
);
|
||||||
const params = $derived(new URLSearchParams(contextSearch));
|
const params = $derived(new URLSearchParams(contextSearch));
|
||||||
const mapQuery = $derived((params.get('mapQ') ?? '').slice(0, 100));
|
const mapQuery = $derived((params.get('mapQ') ?? '').slice(0, 100));
|
||||||
const serverQuery = $derived((params.get('serverQ') ?? '').slice(0, 100));
|
|
||||||
const mapSort = $derived(
|
const mapSort = $derived(
|
||||||
mapColumns.find(
|
mapColumns.find(
|
||||||
(column) => column.key !== 'details' && column.key === params.get('mapSort')
|
(column) => column.key !== 'details' && column.key === params.get('mapSort')
|
||||||
)?.key ?? 'playerHours'
|
)?.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 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 maxPlayerHours = $derived(Math.max(0, ...maps.map((map) => map.playerHours)));
|
||||||
const mapRows = $derived(
|
const mapRows = $derived(
|
||||||
maps
|
maps
|
||||||
@@ -81,47 +59,18 @@
|
|||||||
return result || a.map.localeCompare(b.map) || a.game.localeCompare(b.game);
|
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 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) {
|
function gameName(game: string) {
|
||||||
return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
|
return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
|
||||||
}
|
}
|
||||||
function mapValue(map: MapMetrics, key: Exclude<MapSort, 'map'>): number | null {
|
function mapValue(map: MapMetrics, key: Exclude<MapSort, 'map'>): number | null {
|
||||||
if (key === 'details') return null;
|
if (key === 'details') return null;
|
||||||
if (key === 'matches') return map.matches.started;
|
if (key === 'matches') return map.matches.started;
|
||||||
|
if (key === 'lobbies') return map.running.average;
|
||||||
if (key === 'noEmpty')
|
if (key === 'noEmpty')
|
||||||
return map.availabilityObserved ? map.noEmpty / map.availabilityObserved : null;
|
return map.availabilityObserved ? map.noEmpty / map.availabilityObserved : null;
|
||||||
return (key === 'playerHours' || key === 'active') && !map.observed ? null : map[key];
|
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>) {
|
function updateContext(changes: Record<string, string>) {
|
||||||
const url = new URL(page.url);
|
const url = new URL(page.url);
|
||||||
url.search = contextSearch;
|
url.search = contextSearch;
|
||||||
@@ -130,7 +79,6 @@
|
|||||||
else url.searchParams.delete(key);
|
else url.searchParams.delete(key);
|
||||||
}
|
}
|
||||||
if ('mapQ' in changes || 'mapSort' in changes) url.searchParams.delete('mapPage');
|
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 });
|
replaceState(url, { ...page.state, metricsTableSearch: url.search });
|
||||||
}
|
}
|
||||||
function sortMap(key: MapSort) {
|
function sortMap(key: MapSort) {
|
||||||
@@ -146,233 +94,178 @@
|
|||||||
: 'desc'
|
: 'desc'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function sortServer(key: ServerSort) {
|
|
||||||
updateContext({
|
|
||||||
serverSort: key,
|
|
||||||
serverDirection:
|
|
||||||
key === serverSort
|
|
||||||
? serverDirection === 'asc'
|
|
||||||
? 'desc'
|
|
||||||
: 'asc'
|
|
||||||
: key === 'server'
|
|
||||||
? 'asc'
|
|
||||||
: 'desc'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel" aria-label={view === 'maps' ? 'Map metrics' : 'Instance metrics'}>
|
<section class="panel" aria-label="Map metrics">
|
||||||
<div class="heading">
|
<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">
|
<div class="search">
|
||||||
<Field label={view === 'maps' ? 'Find a map' : 'Find an instance'}
|
<Field label="Find a map"
|
||||||
><Input
|
><Input
|
||||||
type="search"
|
type="search"
|
||||||
value={view === 'maps' ? mapQuery : serverQuery}
|
value={mapQuery}
|
||||||
placeholder={view === 'maps' ? 'Map or game…' : 'Instance name or map…'}
|
placeholder="Map or game…"
|
||||||
oninput={(event) =>
|
oninput={(event) =>
|
||||||
updateContext({
|
updateContext({ mapQ: (event.currentTarget as HTMLInputElement).value })}
|
||||||
[view === 'maps' ? 'mapQ' : 'serverQ']: (
|
|
||||||
event.currentTarget as HTMLInputElement
|
|
||||||
).value
|
|
||||||
})}
|
|
||||||
/></Field
|
/></Field
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if currentPage.total}
|
{#if mapPage.total}
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
||||||
<div
|
<div class="table-wrap" role="region" aria-label="Map metrics table" tabindex="0">
|
||||||
class="table-wrap"
|
|
||||||
role="region"
|
|
||||||
aria-label={view === 'maps' ? 'Map metrics table' : 'Instance metrics table'}
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
<table>
|
<table>
|
||||||
<thead
|
<thead
|
||||||
><tr
|
><tr>
|
||||||
>{#each view === 'maps' ? mapColumns : serverColumns as column}
|
{#each mapColumns as column}
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
aria-sort={column.key === 'details'
|
aria-sort={column.key === 'details'
|
||||||
? undefined
|
? undefined
|
||||||
: (view === 'maps' ? mapSort : serverSort) === column.key
|
: mapSort === column.key
|
||||||
? (view === 'maps' ? mapDirection : serverDirection) === 'asc'
|
? mapDirection === 'asc'
|
||||||
? 'ascending'
|
? 'ascending'
|
||||||
: 'descending'
|
: 'descending'
|
||||||
: undefined}
|
: undefined}
|
||||||
>
|
>
|
||||||
{#if column.key === 'details'}Details{:else}<button
|
{#if column.key === 'details'}Details{:else}<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() =>
|
onclick={() => sortMap(column.key)}
|
||||||
view === 'maps'
|
>
|
||||||
? sortMap(column.key as MapSort)
|
{column.label}<span aria-hidden="true"
|
||||||
: sortServer(column.key as ServerSort)}
|
>{mapSort === column.key
|
||||||
>{column.label}<span aria-hidden="true"
|
? mapDirection === 'asc'
|
||||||
>{(view === 'maps' ? mapSort : serverSort) ===
|
|
||||||
column.key
|
|
||||||
? (view === 'maps'
|
|
||||||
? mapDirection
|
|
||||||
: serverDirection) === 'asc'
|
|
||||||
? '↑'
|
? '↑'
|
||||||
: '↓'
|
: '↓'
|
||||||
: '↕'}</span
|
: '↕'}</span
|
||||||
></button
|
>
|
||||||
>{/if}
|
</button>{/if}
|
||||||
</th>
|
</th>
|
||||||
{/each}</tr
|
{/each}
|
||||||
></thead
|
</tr></thead
|
||||||
>
|
>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#if view === 'maps'}
|
{#each mapPage.rows as map (JSON.stringify([map.game, map.map]))}<tr>
|
||||||
{#each mapPage.rows as map (JSON.stringify([map.game, map.map]))}<tr>
|
<th scope="row"
|
||||||
<th scope="row"
|
>{map.map}<small
|
||||||
>{map.map}<small
|
>{gameName(map.game)}{map.observed < 1 ||
|
||||||
>{gameName(map.game)}{map.observed < 1 ||
|
map.availabilityObserved < 1 ||
|
||||||
map.availabilityObserved < 1 ||
|
map.unknown > map.observed / 4 ||
|
||||||
map.unknown > map.observed / 4 ||
|
map.availabilityUnknown > map.availabilityObserved / 4
|
||||||
map.availabilityUnknown > map.availabilityObserved / 4
|
? ' · Limited evidence'
|
||||||
? ' · Limited evidence'
|
: ''}</small
|
||||||
: ''}</small
|
></th
|
||||||
></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
|
</td>
|
||||||
>{metricNumber(map.matches.started)}<small
|
<td>{metricPercent(map.noEmpty, map.availabilityObserved)}</td>
|
||||||
>{map.matches.averageGroupSize === null
|
<td
|
||||||
? 'Average group unavailable'
|
><details>
|
||||||
: `avg group ${metricNumber(map.matches.averageGroupSize)}`}</small
|
<summary><span class="sr-only">{map.map} </span>Details</summary
|
||||||
></td
|
>
|
||||||
>
|
<dl>
|
||||||
<td class="player-hours"
|
<dt>Matches with recorded players</dt>
|
||||||
>{map.observed ? metricNumber(map.playerHours) : '—'}
|
<dd>
|
||||||
<div class="comparison" aria-hidden="true">
|
{metricNumber(map.matches.withRecordedPlayers)} of {metricNumber(
|
||||||
<i
|
map.matches.started
|
||||||
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}`
|
|
||||||
)}
|
)}
|
||||||
title="Open current instance with this name"
|
</dd>
|
||||||
>{serverTitle(server.server)}</a
|
<dt>Solo matches</dt>
|
||||||
>{:else}{serverTitle(server.server)}<small>Historical</small
|
<dd>
|
||||||
>{/if}</th
|
{map.matches.soloShare === null
|
||||||
>
|
? 'Unavailable · no recorded players'
|
||||||
<td>{metricPercent(server.active, server.observed)}</td><td
|
: `${metricNumber(map.matches.solo)} · ${metricPercent(map.matches.soloShare, 100)} of matches with recorded players`}
|
||||||
>{metricDuration(server.observed)}</td
|
</dd>
|
||||||
><td>{metricDuration(server.unknown)}</td>
|
<dt>Captured map time</dt>
|
||||||
<td
|
<dd>{metricDuration(map.running.capturedHours)}</dd>
|
||||||
><details>
|
<dt>Empty lobby-hours</dt>
|
||||||
<summary
|
<dd>
|
||||||
><span class="sr-only"
|
{map.observed
|
||||||
>{serverTitle(server.server)}
|
? metricDuration(map.idle)
|
||||||
</span>Details</summary
|
: 'Unavailable · no known occupancy'}
|
||||||
>
|
</dd>
|
||||||
<dl>
|
<dt>Observed lobby time</dt>
|
||||||
<dt>Recorded name</dt>
|
<dd>{metricDuration(map.observed)}</dd>
|
||||||
<dd><code>{server.server}</code></dd>
|
<dt>Unknown status</dt>
|
||||||
<dt>Time with players</dt>
|
<dd>{metricDuration(map.unknown)}</dd>
|
||||||
<dd>{metricDuration(server.active)}</dd>
|
<dt>No empty lobby</dt>
|
||||||
<dt>Empty spare</dt>
|
<dd>
|
||||||
<dd>
|
{map.availabilityObserved
|
||||||
{metricPercent(server.idle, server.observed)} · {metricDuration(
|
? `${metricDuration(map.noEmpty)} of ${metricDuration(map.availabilityObserved)} known map time`
|
||||||
server.idle
|
: 'Lobby availability unavailable'}
|
||||||
)}
|
</dd>
|
||||||
</dd>
|
<dt>Unknown map availability</dt>
|
||||||
</dl>
|
<dd>{metricDuration(map.availabilityUnknown)}</dd>
|
||||||
</details></td
|
</dl>
|
||||||
>
|
</details></td
|
||||||
</tr>{/each}
|
>
|
||||||
{/if}
|
</tr>{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{:else}<EmptyState
|
{:else}<EmptyState
|
||||||
title={currentQuery.trim()
|
title={mapQuery.trim() ? 'No maps match this search' : 'No map history in this window'}
|
||||||
? 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
|
|
||||||
>
|
>
|
||||||
{#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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={currentPage.page === 1}
|
disabled={mapPage.page === 1}
|
||||||
onclick={() => updateContext({ [pageKey]: String(currentPage.page - 1) })}
|
onclick={() => updateContext({ mapPage: String(mapPage.page - 1) })}
|
||||||
>Previous</Button
|
>Previous</Button
|
||||||
><span>Page {currentPage.page} of {currentPage.pages}</span><Button
|
>
|
||||||
|
<span>Page {mapPage.page} of {mapPage.pages}</span>
|
||||||
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={currentPage.page === currentPage.pages}
|
disabled={mapPage.page === mapPage.pages}
|
||||||
onclick={() => updateContext({ [pageKey]: String(currentPage.page + 1) })}
|
onclick={() => updateContext({ mapPage: String(mapPage.page + 1) })}
|
||||||
>Next</Button
|
>Next</Button
|
||||||
>
|
>
|
||||||
</div>{/if}
|
</div>{/if}
|
||||||
</div>
|
</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">
|
<details class="table-help">
|
||||||
<summary>About these metrics</summary>
|
<summary>About these metrics</summary>
|
||||||
<p>
|
<p>
|
||||||
{view === 'maps'
|
Matches counts retained matches started in this window, including ongoing and
|
||||||
? '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.'
|
interrupted matches. Average group counts distinct recorded players across each match’s
|
||||||
: '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.'}
|
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>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
@@ -456,6 +349,9 @@
|
|||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
.lobbies {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.comparison {
|
.comparison {
|
||||||
height: 3px;
|
height: 3px;
|
||||||
width: 90px;
|
width: 90px;
|
||||||
@@ -502,12 +398,6 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.identity-note {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0 20px 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
.table-help {
|
.table-help {
|
||||||
padding: 0 20px 16px;
|
padding: 0 20px 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ function validReport(value: unknown): value is MetricsData {
|
|||||||
record(item) &&
|
record(item) &&
|
||||||
totals(item) &&
|
totals(item) &&
|
||||||
matches(item.matches) &&
|
matches(item.matches) &&
|
||||||
|
running(item.running) &&
|
||||||
typeof item.game === 'string' &&
|
typeof item.game === 'string' &&
|
||||||
typeof item.map === 'string' &&
|
typeof item.map === 'string' &&
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -40,11 +40,7 @@
|
|||||||
(page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search
|
(page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search
|
||||||
);
|
);
|
||||||
const view = $derived(
|
const view = $derived(
|
||||||
new URLSearchParams(contextSearch).get('view') === 'instances'
|
new URLSearchParams(contextSearch).get('view') === 'insights' ? 'insights' : 'maps'
|
||||||
? 'instances'
|
|
||||||
: new URLSearchParams(contextSearch).get('view') === 'insights'
|
|
||||||
? 'insights'
|
|
||||||
: 'maps'
|
|
||||||
);
|
);
|
||||||
function changeView(value: string) {
|
function changeView(value: string) {
|
||||||
const url = new URL(page.url);
|
const url = new URL(page.url);
|
||||||
@@ -317,7 +313,6 @@
|
|||||||
<Tabs
|
<Tabs
|
||||||
tabs={[
|
tabs={[
|
||||||
{ id: 'maps', label: 'Maps' },
|
{ id: 'maps', label: 'Maps' },
|
||||||
{ id: 'instances', label: 'Instances' },
|
|
||||||
{ id: 'insights', label: 'Autoscaling signals' }
|
{ id: 'insights', label: 'Autoscaling signals' }
|
||||||
]}
|
]}
|
||||||
value={view}
|
value={view}
|
||||||
@@ -329,8 +324,6 @@
|
|||||||
<div id="metrics-breakdown" role="tabpanel" aria-labelledby={`metrics-views-${view}`}>
|
<div id="metrics-breakdown" role="tabpanel" aria-labelledby={`metrics-views-${view}`}>
|
||||||
{#if view === 'insights'}<MetricsExplore {report} />{:else}<MetricsTables
|
{#if view === 'insights'}<MetricsExplore {report} />{:else}<MetricsTables
|
||||||
maps={report.maps}
|
maps={report.maps}
|
||||||
servers={report.servers}
|
|
||||||
{view}
|
|
||||||
/>{/if}
|
/>{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -413,11 +406,12 @@
|
|||||||
buckets can be partial. Average bars divide occupied, empty spare, and unknown
|
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-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.
|
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>
|
||||||
<p>
|
<p>
|
||||||
Historical server rows are grouped by name and may include multiple registrations using
|
Map groups preserve the recorded game and map label. Each map’s average and peak count
|
||||||
that name. Map groups preserve the recorded game and map label.
|
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>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const policy = {
|
|||||||
const map = (values: Partial<MapMetrics> = {}): MapMetrics => ({
|
const map = (values: Partial<MapMetrics> = {}): MapMetrics => ({
|
||||||
game: 't5',
|
game: 't5',
|
||||||
map: 'Moon',
|
map: 'Moon',
|
||||||
|
running: { average: 2, peak: 2, capturedHours: 5 },
|
||||||
matches: {
|
matches: {
|
||||||
started: 0,
|
started: 0,
|
||||||
withRecordedPlayers: 0,
|
withRecordedPlayers: 0,
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ describe('metrics background refresh', () => {
|
|||||||
game: 't5',
|
game: 't5',
|
||||||
map: 'Moon',
|
map: 'Moon',
|
||||||
matches,
|
matches,
|
||||||
|
running: { average: null, peak: 0, capturedHours: 0 },
|
||||||
noEmpty: 0,
|
noEmpty: 0,
|
||||||
availabilityObserved: 0,
|
availabilityObserved: 0,
|
||||||
availabilityUnknown: 0,
|
availabilityUnknown: 0,
|
||||||
@@ -205,6 +206,7 @@ describe('metrics background refresh', () => {
|
|||||||
game: 't5',
|
game: 't5',
|
||||||
map: 'Moon',
|
map: 'Moon',
|
||||||
matches: emptyMatches,
|
matches: emptyMatches,
|
||||||
|
running: { average: 1, peak: 1, capturedHours: 1 },
|
||||||
noEmpty: 0,
|
noEmpty: 0,
|
||||||
availabilityObserved: 0,
|
availabilityObserved: 0,
|
||||||
availabilityUnknown: 1,
|
availabilityUnknown: 1,
|
||||||
@@ -232,6 +234,19 @@ describe('metrics background refresh', () => {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
const invalid = [
|
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, autoscalingPaused: undefined },
|
||||||
{ ...value, running: undefined },
|
{ ...value, running: undefined },
|
||||||
{ ...value, previous: { ...value.previous, running: undefined } },
|
{ ...value, previous: { ...value.previous, running: undefined } },
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ describe('historical metrics', () => {
|
|||||||
});
|
});
|
||||||
expect(result.maps).toHaveLength(1);
|
expect(result.maps).toHaveLength(1);
|
||||||
expect(result.maps[0].matches).toEqual(result.matches);
|
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.registeredServerCount).toBe(0);
|
||||||
expect(result.servers).toEqual([]);
|
expect(result.servers).toEqual([]);
|
||||||
expect(result.scopeOptions.maps).not.toContainEqual({ game: 't5', map: 'Future Map' });
|
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.maps[0].availabilityUnknown * 3600).toBeCloseTo(90);
|
||||||
expect(later.pooledAvailability.value).toBeNull();
|
expect(later.pooledAvailability.value).toBeNull();
|
||||||
expect(initial.running).toEqual({ average: null, peak: 0, capturedHours: 0 });
|
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 () => {
|
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.average).toBeCloseTo(1.6);
|
||||||
expect(result.running.peak).toBe(3);
|
expect(result.running.peak).toBe(3);
|
||||||
expect(result.running.capturedHours * 3600).toBeCloseTo(150);
|
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.average).toBe(4);
|
||||||
expect(result.previous.running.peak).toBe(4);
|
expect(result.previous.running.peak).toBe(4);
|
||||||
expect(result.previous.running.capturedHours * 3600).toBeCloseTo(90);
|
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 () => {
|
it('matches current policy context through map aliases and prefers the durable owner', async () => {
|
||||||
const { recordMetrics, readMetrics } =
|
const { recordMetrics, readMetrics } =
|
||||||
await import('../../src/lib/server/fleet/metrics.js');
|
await import('../../src/lib/server/fleet/metrics.js');
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const matches = (changes: Partial<MatchMetrics> = {}): MatchMetrics => ({
|
|||||||
...changes
|
...changes
|
||||||
});
|
});
|
||||||
const map = (i: number, changes: Partial<MapMetrics> = {}): MapMetrics => ({
|
const map = (i: number, changes: Partial<MapMetrics> = {}): MapMetrics => ({
|
||||||
|
running: { average: 2.5, peak: 3, capturedHours: 4 },
|
||||||
game: 't5',
|
game: 't5',
|
||||||
map: `Map ${String(i).padStart(2, '0')}`,
|
map: `Map ${String(i).padStart(2, '0')}`,
|
||||||
matches: matches({
|
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', () => {
|
it('searches and sorts the complete map set before pagination and clamps invalid pages', () => {
|
||||||
const data = report();
|
const data = report();
|
||||||
scope('?mapPage=2');
|
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).toContain('Map 09');
|
||||||
expect(body).not.toContain('Map 24');
|
expect(body).not.toContain('Map 24');
|
||||||
scope('?mapPage=2', '?mapQ=Map+24&mapPage=100&mapSort=playerHours');
|
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).toContain('Map 24');
|
||||||
expect(body.replace(/\s+/g, ' ')).toContain('1–1 of 1 maps');
|
expect(body.replace(/\s+/g, ' ')).toContain('1–1 of 1 maps');
|
||||||
expect(body).not.toContain('>Next<');
|
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 } })
|
map(4, { matches: { ...noRoster, started: 0 } })
|
||||||
];
|
];
|
||||||
scope('?mapSort=matches&mapDirection=asc');
|
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 04')).toBeLessThan(body.indexOf('Map 02'));
|
||||||
expect(body.indexOf('Map 02')).toBeLessThan(body.indexOf('Map 03'));
|
expect(body.indexOf('Map 02')).toBeLessThan(body.indexOf('Map 03'));
|
||||||
expect(body.indexOf('Map 03')).toBeLessThan(body.indexOf('Map 01'));
|
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');
|
expect(body.replace(/\s+/g, ' ')).toContain('0 of 100');
|
||||||
|
|
||||||
scope('?mapSort=matches&mapDirection=desc');
|
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 01')).toBeLessThan(body.indexOf('Map 03'));
|
||||||
expect(body.indexOf('Map 03')).toBeLessThan(body.indexOf('Map 02'));
|
expect(body.indexOf('Map 03')).toBeLessThan(body.indexOf('Map 02'));
|
||||||
expect(body.indexOf('Map 02')).toBeLessThan(body.indexOf('Map 04'));
|
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 = [
|
data.maps = [
|
||||||
map(1, {
|
map(1, {
|
||||||
...data.summary,
|
...data.summary,
|
||||||
|
running: data.running,
|
||||||
noEmpty: 0,
|
noEmpty: 0,
|
||||||
availabilityObserved: 0,
|
availabilityObserved: 0,
|
||||||
matches: data.matches
|
matches: data.matches
|
||||||
@@ -302,7 +304,7 @@ it('keeps unknown availability last when sorting and distinguishes it from known
|
|||||||
map(2, { noEmpty: 0 }),
|
map(2, { noEmpty: 0 }),
|
||||||
map(3)
|
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 02')).toBeLessThan(body.indexOf('Map 03'));
|
||||||
expect(body.indexOf('Map 03')).toBeLessThan(body.indexOf('Map 01'));
|
expect(body.indexOf('Map 03')).toBeLessThan(body.indexOf('Map 01'));
|
||||||
expect(body).toContain('0.0%');
|
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');
|
expect(body).not.toContain('All lobbies full');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves instance return context and distinguishes historic names without current links', () => {
|
it.each(['', '?view=instances&serverQ=theater&days=30'])(
|
||||||
scope('?view=instances&serverQ=theater&days=30');
|
'shows map lobby metrics without an Instances tab for %s',
|
||||||
const body = render(MetricsTables, {
|
(search) => {
|
||||||
props: { maps: [], servers: [server(1), server(2)], view: 'instances' }
|
scope(search);
|
||||||
}).body;
|
const body = render(MetricsPage, {
|
||||||
expect(body).toContain('Historical');
|
props: { data: { ...report(), user: null, setupRequired: false } }
|
||||||
expect(body).not.toContain('href="/servers/t5_zombie_theater_1');
|
}).body;
|
||||||
expect(body).toContain('href="/servers/t5_zombie_theater_2?returnTo=');
|
expect(body).toContain('aria-label="Map metrics table"');
|
||||||
expect(body).toContain('serverQ%3Dtheater');
|
expect(body).toContain('aria-labelledby="metrics-views-maps"');
|
||||||
expect(body).not.toContain('>Idle</button>');
|
expect(body).toContain('Lobbies (avg · peak)');
|
||||||
expect(body).not.toContain('Open current server');
|
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', () => {
|
it('highlights at most three supported policy changes and collapses tentative findings', () => {
|
||||||
const data = report();
|
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', () => {
|
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, {
|
let body = render(MetricsPage, {
|
||||||
props: { data: { ...report(), user: null, setupRequired: false } }
|
props: { data: { ...report(), user: null, setupRequired: false } }
|
||||||
}).body;
|
}).body;
|
||||||
expect(body).toContain('aria-label="Instance metrics table"');
|
expect(body).toContain('aria-label="Map metrics table"');
|
||||||
expect(body).not.toContain('aria-label="Map metrics table"');
|
expect(body).not.toContain('id="metrics-explore-title"');
|
||||||
expect(body.replace(/\s+/g, ' ')).toContain('16–24 of 24 instances');
|
expect(body.replace(/\s+/g, ' ')).toContain('16–24 of 24 maps');
|
||||||
scope('?view=maps', '?view=insights');
|
scope('?view=maps', '?view=insights');
|
||||||
body = render(MetricsPage, {
|
body = render(MetricsPage, {
|
||||||
props: { data: { ...report(), user: null, setupRequired: false } }
|
props: { data: { ...report(), user: null, setupRequired: false } }
|
||||||
|
|||||||
Reference in New Issue
Block a user