This commit is contained in:
@@ -56,9 +56,22 @@ export interface ServerMetrics extends Utilization {
|
||||
currentRegistered: boolean;
|
||||
}
|
||||
|
||||
export interface MatchMetrics {
|
||||
/** Retained matches whose recorded start is within the selected window. */
|
||||
started: number;
|
||||
/** Matches with at least one distinct recorded participant. */
|
||||
withRecordedPlayers: number;
|
||||
/** Distinct participants over each match's lifetime, not simultaneous players. */
|
||||
averageGroupSize: number | null;
|
||||
solo: number;
|
||||
/** Percentage of matches with recorded participants, from 0 to 100. */
|
||||
soloShare: number | null;
|
||||
}
|
||||
|
||||
export interface MapMetrics extends Utilization {
|
||||
game: string;
|
||||
map: string;
|
||||
matches: MatchMetrics;
|
||||
/** Map wall time, not summed lobby time. Capacity does not affect availability. */
|
||||
noEmpty: number;
|
||||
availabilityObserved: number;
|
||||
@@ -139,6 +152,7 @@ export interface MetricsData {
|
||||
summary: Utilization;
|
||||
running: RunningMetrics;
|
||||
servers: ServerMetrics[];
|
||||
matches: MatchMetrics;
|
||||
maps: MapMetrics[];
|
||||
points: MetricPoint[];
|
||||
previous: PreviousMetrics;
|
||||
|
||||
@@ -5,7 +5,11 @@ import { ensureAutoscalingSchema } from '../autoscaling/schema.js';
|
||||
import { ensureRebalancingSchema } from '../rebalancing/schema.js';
|
||||
import { migrateFileBlobs } from './file-blobs.js';
|
||||
import { installPerformanceIndexes } from './performance-indexes.js';
|
||||
import { installMetricsPolicyRevisions, installReportRevisions } from '../reports/revisions.js';
|
||||
import {
|
||||
installMetricsMatchRevisions,
|
||||
installMetricsPolicyRevisions,
|
||||
installReportRevisions
|
||||
} from '../reports/revisions.js';
|
||||
import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js';
|
||||
import { migrateHostSchema } from '../hosts/schema.js';
|
||||
import { migrateAuthSchema } from '../auth/schema.js';
|
||||
@@ -70,6 +74,11 @@ export const migrations: readonly Migration[] = [
|
||||
version: 13,
|
||||
name: 'metrics policy report revisions',
|
||||
run: installMetricsPolicyRevisions
|
||||
},
|
||||
{
|
||||
version: 14,
|
||||
name: 'metrics match report revisions',
|
||||
run: installMetricsMatchRevisions
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
+110
-23
@@ -6,6 +6,7 @@ import {
|
||||
parseMetricsQuery,
|
||||
type HeatmapCell,
|
||||
type MapMetrics,
|
||||
type MatchMetrics,
|
||||
type MetricPoint,
|
||||
type MetricsData,
|
||||
type MetricsDays,
|
||||
@@ -47,6 +48,27 @@ function emptyRunning(): RunningMetrics {
|
||||
return { average: null, peak: 0, capturedHours: 0 };
|
||||
}
|
||||
|
||||
interface MatchTotals {
|
||||
started: number;
|
||||
withRecordedPlayers: number;
|
||||
participants: number;
|
||||
solo: number;
|
||||
}
|
||||
|
||||
function matchMetrics(totals: MatchTotals): MatchMetrics {
|
||||
return {
|
||||
started: totals.started,
|
||||
withRecordedPlayers: totals.withRecordedPlayers,
|
||||
averageGroupSize:
|
||||
totals.withRecordedPlayers > 0
|
||||
? totals.participants / totals.withRecordedPlayers
|
||||
: null,
|
||||
solo: totals.solo,
|
||||
soloShare:
|
||||
totals.withRecordedPlayers > 0 ? (100 * totals.solo) / totals.withRecordedPlayers : null
|
||||
};
|
||||
}
|
||||
|
||||
function addRunning(total: RunningMetrics, count: number, hours: number): void {
|
||||
if (hours <= 0) return;
|
||||
const lobbyHours = (total.average ?? 0) * total.capturedHours + count * hours;
|
||||
@@ -183,9 +205,25 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
scopeFirstSampleAt: scopedBounds.first,
|
||||
scopeLatestSampleAt: scopedBounds.latest
|
||||
};
|
||||
// Match history has independent retention and may outlive occupancy samples.
|
||||
// Preserve exact recorded labels, including distinct T6 variants.
|
||||
const matchMaps = handle
|
||||
.prepare(
|
||||
`SELECT DISTINCT game, map_name AS map FROM leaderboard_matches WHERE started_at <= ?`
|
||||
)
|
||||
.all(new Date(now).toISOString()) as { game: string; map: string }[];
|
||||
const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
const availableMaps = [
|
||||
...new Map(
|
||||
[...groups, ...matchMaps].map(({ game, map }) => [
|
||||
JSON.stringify([game, map]),
|
||||
{ game, map }
|
||||
])
|
||||
).values()
|
||||
].sort((a, b) => compareText(a.game, b.game) || compareText(a.map, b.map));
|
||||
const scopeOptions = {
|
||||
games: [...new Set(groups.map((group) => group.game))],
|
||||
maps: groups.map(({ game, map }) => ({ game, map }))
|
||||
games: [...new Set(availableMaps.map((group) => group.game))],
|
||||
maps: availableMaps
|
||||
};
|
||||
const summary = emptyUtilization();
|
||||
const running = emptyRunning();
|
||||
@@ -200,6 +238,74 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
};
|
||||
const serverTotals = new Map<string, ServerMetrics>();
|
||||
const mapTotals = new Map<string, Map<string, MapMetrics>>();
|
||||
function mapFor(gameName: string, label: string): MapMetrics {
|
||||
let game = mapTotals.get(gameName);
|
||||
if (!game) {
|
||||
game = new Map();
|
||||
mapTotals.set(gameName, game);
|
||||
}
|
||||
let map = game.get(label);
|
||||
if (!map) {
|
||||
map = {
|
||||
game: gameName,
|
||||
map: label,
|
||||
matches: matchMetrics({
|
||||
started: 0,
|
||||
withRecordedPlayers: 0,
|
||||
participants: 0,
|
||||
solo: 0
|
||||
}),
|
||||
noEmpty: 0,
|
||||
availabilityObserved: 0,
|
||||
availabilityUnknown: 0,
|
||||
noEmptyAtMaximum: 0,
|
||||
noEmptyBelowMaximum: 0,
|
||||
policy: policyFor(gameName, label),
|
||||
...emptyUtilization()
|
||||
};
|
||||
game.set(label, map);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
const matchConditions = ['m.started_at >= ?', 'm.started_at <= ?'];
|
||||
const matchParams = [new Date(since).toISOString(), new Date(now).toISOString()];
|
||||
if (scope.game !== null) {
|
||||
matchConditions.push('m.game = ?');
|
||||
matchParams.push(scope.game);
|
||||
}
|
||||
if (scope.map !== null) {
|
||||
matchConditions.push('m.map_name = ?');
|
||||
matchParams.push(scope.map);
|
||||
}
|
||||
// Group participants per match before aggregating maps, so roster size never
|
||||
// multiplies the start count. The participant PK deduplicates returning players.
|
||||
const matchGroups = handle
|
||||
.prepare(
|
||||
`SELECT game, map, COUNT(*) AS started,
|
||||
SUM(players > 0) AS withRecordedPlayers,
|
||||
SUM(players) AS participants, SUM(players = 1) AS solo
|
||||
FROM (
|
||||
SELECT m.game, m.map_name AS map, COUNT(p.guid) AS players
|
||||
FROM leaderboard_matches m
|
||||
LEFT JOIN leaderboard_match_players p ON p.match_id = m.id
|
||||
WHERE ${matchConditions.join(' AND ')}
|
||||
GROUP BY m.id
|
||||
) GROUP BY game, map`
|
||||
)
|
||||
.all(...matchParams) as (MatchTotals & { game: string; map: string })[];
|
||||
const totalMatches: MatchTotals = {
|
||||
started: 0,
|
||||
withRecordedPlayers: 0,
|
||||
participants: 0,
|
||||
solo: 0
|
||||
};
|
||||
for (const group of matchGroups) {
|
||||
mapFor(group.game, group.map).matches = matchMetrics(group);
|
||||
totalMatches.started += group.started;
|
||||
totalMatches.withRecordedPlayers += group.withRecordedPlayers;
|
||||
totalMatches.participants += group.participants;
|
||||
totalMatches.solo += group.solo;
|
||||
}
|
||||
const points: MetricPoint[] = [];
|
||||
for (let at = Math.floor(since / bucketMs) * bucketMs; at < now; at += bucketMs) {
|
||||
const end = Math.min(at + bucketMs, now);
|
||||
@@ -327,26 +433,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
};
|
||||
serverTotals.set(sample.server, server);
|
||||
}
|
||||
let game = mapTotals.get(sample.game);
|
||||
if (!game) {
|
||||
game = new Map();
|
||||
mapTotals.set(sample.game, game);
|
||||
}
|
||||
let map = game.get(sample.map);
|
||||
if (!map) {
|
||||
map = {
|
||||
game: sample.game,
|
||||
map: sample.map,
|
||||
noEmpty: 0,
|
||||
availabilityObserved: 0,
|
||||
availabilityUnknown: 0,
|
||||
noEmptyAtMaximum: 0,
|
||||
noEmptyBelowMaximum: 0,
|
||||
policy: policyFor(sample.game, sample.map),
|
||||
...emptyUtilization()
|
||||
};
|
||||
game.set(sample.map, map);
|
||||
}
|
||||
const map = mapFor(sample.game, sample.map);
|
||||
addObservation(summary, sample.players, hours);
|
||||
addObservation(server, sample.players, hours);
|
||||
addObservation(map, sample.players, hours);
|
||||
@@ -404,7 +491,6 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
}
|
||||
aggregateBatch(now);
|
||||
|
||||
const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
const servers = [...serverTotals.values()].sort(
|
||||
(a, b) => b.active - a.active || compareText(a.server, b.server)
|
||||
);
|
||||
@@ -442,6 +528,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
scopeOptions,
|
||||
summary,
|
||||
running,
|
||||
matches: matchMetrics(totalMatches),
|
||||
servers,
|
||||
maps,
|
||||
points,
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
/** Match demand includes active and interrupted matches, independent of leaderboard eligibility. */
|
||||
export function installMetricsMatchRevisions(handle: Database.Database) {
|
||||
for (const table of ['leaderboard_matches', 'leaderboard_match_players'])
|
||||
for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
|
||||
const fields =
|
||||
table === 'leaderboard_matches' ? ['game', 'map_name', 'started_at'] : ['match_id'];
|
||||
// Score/presence heartbeats do not change distinct participant counts.
|
||||
const condition =
|
||||
action === 'UPDATE'
|
||||
? `WHEN ${fields.map((field) => `OLD.${field} IS NOT NEW.${field}`).join(' OR ')}`
|
||||
: '';
|
||||
handle.exec(`CREATE TRIGGER IF NOT EXISTS report_metrics_${table}_${action}
|
||||
AFTER ${action} ON ${table} ${condition}
|
||||
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='metrics'; END;`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Current policy settings are inputs to historical metrics advice. */
|
||||
export function installMetricsPolicyRevisions(handle: Database.Database) {
|
||||
for (const table of ['autoscaling_policies', 'settings'])
|
||||
|
||||
@@ -63,6 +63,31 @@
|
||||
<p>Players × observed time. One player for one hour equals one player-hour.</p>
|
||||
</details>
|
||||
</Card>
|
||||
<Card
|
||||
><Stat
|
||||
label="Matches started"
|
||||
value={metricNumber(report.matches.started)}
|
||||
size="lg"
|
||||
tween={false}
|
||||
/>
|
||||
<p>
|
||||
{report.matches.averageGroupSize === null
|
||||
? 'Average group unavailable'
|
||||
: `Average group ${metricNumber(report.matches.averageGroupSize)} ${plural(report.matches.averageGroupSize, 'player')}`}
|
||||
</p>
|
||||
<small
|
||||
>{report.matches.soloShare === null
|
||||
? 'Solo share unavailable · no recorded players'
|
||||
: `${metricNumber(report.matches.solo)} solo · ${metricPercent(report.matches.soloShare, 100)} of matches with recorded players`}</small
|
||||
>
|
||||
{#if report.matches.withRecordedPlayers < report.matches.started}<small
|
||||
>Players recorded for {metricNumber(report.matches.withRecordedPlayers)} of {countLabel(
|
||||
report.matches.started,
|
||||
'match',
|
||||
'matches'
|
||||
)}</small
|
||||
>{/if}
|
||||
</Card>
|
||||
<Card
|
||||
><Stat
|
||||
label="Time with no empty lobby"
|
||||
@@ -89,7 +114,7 @@
|
||||
<style>
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
p {
|
||||
@@ -117,6 +142,11 @@
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.summary {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
}: { maps: MapMetrics[]; servers: ServerMetrics[]; view?: 'maps' | 'instances' } = $props();
|
||||
const mapColumns = [
|
||||
{ key: 'map', label: 'Map' },
|
||||
{ key: 'matches', label: 'Matches' },
|
||||
{ key: 'playerHours', label: 'Player-hours' },
|
||||
{ key: 'active', label: 'With players' },
|
||||
{ key: 'noEmpty', label: 'No empty lobby' },
|
||||
@@ -103,12 +104,14 @@
|
||||
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 === 'noEmpty')
|
||||
return map.availabilityObserved ? map.noEmpty / map.availabilityObserved : null;
|
||||
return (key === 'playerHours' || key === 'active') && !map.observed ? null : map[key];
|
||||
@@ -233,6 +236,13 @@
|
||||
: ''}</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">
|
||||
@@ -249,6 +259,18 @@
|
||||
><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>
|
||||
@@ -308,12 +330,19 @@
|
||||
</table>
|
||||
</div>
|
||||
{:else}<EmptyState
|
||||
title={view === 'maps' ? 'No maps match this search' : 'No instances match this search'}
|
||||
>{#snippet actions()}<Button
|
||||
size="sm"
|
||||
onclick={() => updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: '' })}
|
||||
>Clear search</Button
|
||||
>{/snippet}</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
|
||||
@@ -342,7 +371,7 @@
|
||||
<summary>About these metrics</summary>
|
||||
<p>
|
||||
{view === 'maps'
|
||||
? '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.'
|
||||
? '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.'}
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -17,6 +17,13 @@ const running = (value: unknown) =>
|
||||
(value.average === null || nonnegative(value.average)) &&
|
||||
nonnegative(value.peak) &&
|
||||
nonnegative(value.capturedHours);
|
||||
const matches = (value: unknown) =>
|
||||
record(value) &&
|
||||
['started', 'withRecordedPlayers', 'solo'].every(
|
||||
(key) => nonnegative(value[key]) && Number.isInteger(value[key])
|
||||
) &&
|
||||
(value.averageGroupSize === null || nonnegative(value.averageGroupSize)) &&
|
||||
(value.soloShare === null || (nonnegative(value.soloShare) && value.soloShare <= 100));
|
||||
const policy = (value: unknown) =>
|
||||
value === null ||
|
||||
(record(value) &&
|
||||
@@ -32,7 +39,8 @@ function validReport(value: unknown): value is MetricsData {
|
||||
!numeric(value.now) ||
|
||||
!numeric(value.since) ||
|
||||
!totals(value.summary) ||
|
||||
!running(value.running)
|
||||
!running(value.running) ||
|
||||
!matches(value.matches)
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
@@ -84,6 +92,7 @@ function validReport(value: unknown): value is MetricsData {
|
||||
(item) =>
|
||||
record(item) &&
|
||||
totals(item) &&
|
||||
matches(item.matches) &&
|
||||
typeof item.game === 'string' &&
|
||||
typeof item.map === 'string' &&
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user