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:
@@ -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' &&
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user