Make metrics reflect autoscaling policies (Refs #137)
CI / checks (push) Successful in 4m49s
CI / checks (push) Successful in 4m49s
This commit is contained in:
+79
-62
@@ -1,113 +1,130 @@
|
||||
import type { MapMetrics } from './metrics.js';
|
||||
import { metricDuration, metricPercent } from './ui/metrics.js';
|
||||
|
||||
/** Product hypotheses for advisory experiments; these are not demand forecasts. */
|
||||
export const HOSTING_RULES = {
|
||||
pressureExpansion: 0.2,
|
||||
occupancyExpansion: 0.8,
|
||||
idleUtilization: 0.1,
|
||||
quietPressure: 0.01,
|
||||
minimumEvidenceHours: 1
|
||||
/** Advisory thresholds for reviewing a policy, not demand forecasts or scaling commands. */
|
||||
export const POLICY_SIGNAL_RULES = {
|
||||
noEmptyShare: 0.02,
|
||||
minimumEvidenceHours: 1,
|
||||
minimumKnownShare: 0.8
|
||||
} as const;
|
||||
|
||||
export interface MetricRecommendation {
|
||||
game: string;
|
||||
map: string;
|
||||
kind: 'expand' | 'consolidate' | 'capacity' | 'watch';
|
||||
kind: 'maximum' | 'spares' | 'policy' | 'reporting' | 'watch';
|
||||
title: string;
|
||||
reason: string;
|
||||
evidence: string;
|
||||
outcome: string;
|
||||
tentative: boolean;
|
||||
policySummary: string | null;
|
||||
href: string | null;
|
||||
action: string | null;
|
||||
}
|
||||
|
||||
/** Apply the same visible rules to all selected maps, including sparse observations. */
|
||||
/** Compare captured availability with the current policy; historical settings are not recorded. */
|
||||
export function metricRecommendations(maps: MapMetrics[]): MetricRecommendation[] {
|
||||
return maps
|
||||
.map((map) => {
|
||||
const utilization = map.observed > 0 ? map.active / map.observed : null;
|
||||
const pressure = map.capacityObserved > 0 ? map.full / map.capacityObserved : null;
|
||||
const knownShare =
|
||||
map.observed + map.unknown > 0 ? map.observed / (map.observed + map.unknown) : 0;
|
||||
.map((map): MetricRecommendation => {
|
||||
const availability = map.availabilityObserved + map.availabilityUnknown;
|
||||
const knownShare = availability > 0 ? map.availabilityObserved / availability : 0;
|
||||
const lobbyHours = map.observed + map.unknown;
|
||||
const knownLobbyShare = lobbyHours > 0 ? map.observed / lobbyHours : 0;
|
||||
const noEmptyShare =
|
||||
map.availabilityObserved > 0 ? map.noEmpty / map.availabilityObserved : null;
|
||||
const policy = map.policy;
|
||||
const base = {
|
||||
game: map.game,
|
||||
map: map.map,
|
||||
evidence:
|
||||
`${metricDuration(map.active)} with players / ${metricDuration(map.observed)} known server time (${metricPercent(map.active, map.observed)}). ` +
|
||||
(pressure === null
|
||||
? 'Capacity unavailable.'
|
||||
: `All lobbies full ${metricDuration(map.full)} / ${metricDuration(map.capacityObserved)} known map time (${metricPercent(map.full, map.capacityObserved)}).`),
|
||||
`${metricDuration(map.active)} with players / ${metricDuration(map.observed)} known lobby time (${metricPercent(map.active, map.observed)}). ` +
|
||||
(map.unknown > 0
|
||||
? `${metricDuration(map.unknown)} of lobby time has unknown occupancy. `
|
||||
: '') +
|
||||
(noEmptyShare === null
|
||||
? 'Lobby availability is unknown.'
|
||||
: `No empty lobby reported for ${metricDuration(map.noEmpty)} / ${metricDuration(map.availabilityObserved)} known map time (${metricPercent(map.noEmpty, map.availabilityObserved)}).`) +
|
||||
(policy && noEmptyShare !== null
|
||||
? ` At or above the current maximum (${policy.maximum}): ${metricDuration(map.noEmptyAtMaximum)}; below it: ${metricDuration(map.noEmptyBelowMaximum)}.`
|
||||
: ''),
|
||||
tentative:
|
||||
map.observed < HOSTING_RULES.minimumEvidenceHours ||
|
||||
pressure === null ||
|
||||
map.capacityObserved < HOSTING_RULES.minimumEvidenceHours ||
|
||||
knownShare < 0.8
|
||||
map.availabilityObserved < POLICY_SIGNAL_RULES.minimumEvidenceHours ||
|
||||
knownShare < POLICY_SIGNAL_RULES.minimumKnownShare ||
|
||||
knownLobbyShare < POLICY_SIGNAL_RULES.minimumKnownShare,
|
||||
policySummary: policy
|
||||
? `Current policy: ${policy.minimum}–${policy.maximum} lobbies, ${policy.spares} spare${policy.spares === 1 ? '' : 's'}`
|
||||
: null,
|
||||
href: policy ? `/autoscaling#${encodeURIComponent(policy.id)}` : null,
|
||||
action: policy ? 'Review policy' : null
|
||||
};
|
||||
if (utilization === null)
|
||||
if (noEmptyShare === null)
|
||||
return {
|
||||
...base,
|
||||
kind: 'capacity' as const,
|
||||
kind: 'reporting',
|
||||
title:
|
||||
map.unknown > 0
|
||||
map.availabilityUnknown > 0 || map.unknown > 0
|
||||
? 'Check occupancy reporting'
|
||||
: 'Let observations accumulate',
|
||||
reason:
|
||||
map.unknown > 0
|
||||
? 'Captured status is unknown. Check a server’s telemetry or RCON diagnostics before choosing a hosting change.'
|
||||
: 'This map has no elapsed observations in the selected window. Wait for another status check or try a wider range.',
|
||||
outcome: 'Watch for known server time in the next report.',
|
||||
map.availabilityUnknown > 0 || map.unknown > 0
|
||||
? 'Captured occupancy is unknown. Check a lobby’s telemetry or RCON diagnostics before choosing a policy change.'
|
||||
: 'This map has no elapsed availability observations in the selected window. Wait for another status check or try a wider range.',
|
||||
outcome: 'Watch for known lobby availability in the next report.',
|
||||
tentative: true
|
||||
};
|
||||
if (pressure !== null && pressure >= HOSTING_RULES.pressureExpansion)
|
||||
if (!policy)
|
||||
return {
|
||||
...base,
|
||||
kind: 'expand' as const,
|
||||
title: 'Trial one additional lobby',
|
||||
reason: 'Recorded lobbies were all full for at least 20% of known capacity time. Try one extra lobby during busy hours.',
|
||||
kind: 'policy',
|
||||
title: 'Consider adding an autoscaling policy',
|
||||
reason: 'This map has no current autoscaling policy. Set a minimum, maximum, and spare target to maintain an empty lobby as players arrive.',
|
||||
outcome:
|
||||
'Compare all-full time and use of the additional lobby over the next comparable period.'
|
||||
'Choose initial limits from observed lobby use, then compare empty-lobby availability over a comparable period.',
|
||||
href: '/autoscaling',
|
||||
action: 'Add policy'
|
||||
};
|
||||
if (pressure === null && utilization >= HOSTING_RULES.occupancyExpansion)
|
||||
if (policy.pausedReason || policy.mode === 'off')
|
||||
return {
|
||||
...base,
|
||||
kind: 'expand' as const,
|
||||
title: 'Trial an extra lobby during busy hours',
|
||||
reason: 'Players were present for at least 80% of known server time. Capacity is unknown, so this experiment uses occupancy as a proxy, not evidence of saturation.',
|
||||
kind: 'policy',
|
||||
title: policy.pausedReason
|
||||
? 'Review and resume the autoscaling policy'
|
||||
: 'Review and enable the autoscaling policy',
|
||||
reason: policy.pausedReason
|
||||
? `The current policy is paused: ${policy.pausedReason}`
|
||||
: 'The current policy is off, so it cannot maintain the spare target.',
|
||||
outcome:
|
||||
'Check whether players use the extra lobby, and measure capacity if the server reports it.',
|
||||
tentative: true
|
||||
'Review the policy and any blockers before enabling it, then observe availability before adjusting its limits.'
|
||||
};
|
||||
if (
|
||||
utilization <= HOSTING_RULES.idleUtilization &&
|
||||
(pressure === null || pressure < HOSTING_RULES.quietPressure)
|
||||
)
|
||||
if (noEmptyShare >= POLICY_SIGNAL_RULES.noEmptyShare) {
|
||||
if (map.noEmptyAtMaximum >= map.noEmptyBelowMaximum)
|
||||
return {
|
||||
...base,
|
||||
kind: 'maximum',
|
||||
title: 'Consider raising the policy maximum',
|
||||
reason: `No empty lobby was reported for at least 2% of known map time. At least half of that time had a tracked lobby count at or above the current maximum of ${policy.maximum}; review whether that limit leaves room for the spare target.`,
|
||||
outcome:
|
||||
'Review host resources and create time, then compare empty-lobby availability after a maximum change.'
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
kind: 'consolidate' as const,
|
||||
title: 'Trial less idle hosting',
|
||||
reason: 'Players were present for at most 10% of known time, with less than 1% measured pressure or unavailable capacity. Review one idle lobby or a shorter hosting window; preserve access to this map.',
|
||||
kind: 'spares',
|
||||
title: 'Review the spare target and create time',
|
||||
reason: `No empty lobby was reported for at least 2% of known map time. Most of that time was below the current maximum of ${policy.maximum}; consider raising the spare target from ${policy.spares} and check how long new lobbies take to become ready.`,
|
||||
outcome:
|
||||
'Watch for increased all-full time or loss of player-hours, and restore the lineup if the trial hurts availability.'
|
||||
};
|
||||
if (pressure === null)
|
||||
return {
|
||||
...base,
|
||||
kind: 'capacity' as const,
|
||||
title: 'Check capacity during busy hours',
|
||||
reason: 'Occupancy is recorded, but player limits are unavailable. Check capacity reporting while keeping the current lineup.',
|
||||
outcome:
|
||||
'Look for known-capacity time and all-full periods before the next hosting trial.'
|
||||
'Review creation delays and blockers, then compare empty-lobby availability after a spare-target change.'
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
kind: 'watch' as const,
|
||||
title: 'Keep the lineup and watch peaks',
|
||||
reason: 'These observations do not trigger the expansion or idle-hosting rules. Keep the current lineup for another comparison period.',
|
||||
outcome: 'Watch whether occupied time and measured capacity pressure change.'
|
||||
kind: 'watch',
|
||||
title: 'Keep the policy and watch availability',
|
||||
reason: 'No-empty-lobby time is below the policy review threshold. These observations do not suggest increasing the maximum or spare target.',
|
||||
outcome: 'Watch for recurring periods without a reported empty lobby.'
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const priority = { expand: 0, consolidate: 1, capacity: 2, watch: 3 };
|
||||
const priority = { maximum: 0, spares: 1, policy: 2, reporting: 3, watch: 4 };
|
||||
return (
|
||||
priority[a.kind] - priority[b.kind] ||
|
||||
a.map.localeCompare(b.map) ||
|
||||
|
||||
+32
-7
@@ -1,4 +1,5 @@
|
||||
import { DEFAULT_TIME_ZONE, validTimeZone } from './time-zone.js';
|
||||
import type { AutoscalingMode } from './autoscaling.js';
|
||||
|
||||
export type MetricsDays = 1 | 7 | 30;
|
||||
|
||||
@@ -58,9 +59,28 @@ export interface ServerMetrics extends Utilization {
|
||||
export interface MapMetrics extends Utilization {
|
||||
game: string;
|
||||
map: string;
|
||||
/** Map wall time, not summed server time. */
|
||||
full: number;
|
||||
capacityObserved: number;
|
||||
/** Map wall time, not summed lobby time. Capacity does not affect availability. */
|
||||
noEmpty: number;
|
||||
availabilityObserved: number;
|
||||
availabilityUnknown: number;
|
||||
/** Historical lobby counts compared with the current policy's maximum. */
|
||||
noEmptyAtMaximum: number;
|
||||
noEmptyBelowMaximum: number;
|
||||
policy: {
|
||||
id: string;
|
||||
mode: AutoscalingMode;
|
||||
minimum: number;
|
||||
maximum: number;
|
||||
spares: number;
|
||||
pausedReason: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface RunningMetrics {
|
||||
/** Concurrent tracked lobbies, including unknown player status, during captured scope time. */
|
||||
average: number | null;
|
||||
peak: number;
|
||||
capturedHours: number;
|
||||
}
|
||||
|
||||
export interface MetricPoint extends Utilization {
|
||||
@@ -69,12 +89,15 @@ export interface MetricPoint extends Utilization {
|
||||
startAt: number;
|
||||
endAt: number;
|
||||
partial: boolean;
|
||||
/** Captured scope wall time, excluding collection gaps. */
|
||||
capturedHours: number;
|
||||
}
|
||||
|
||||
export interface PreviousMetrics {
|
||||
since: number;
|
||||
until: number;
|
||||
summary: Utilization;
|
||||
running: RunningMetrics;
|
||||
firstSampleAt: number | null;
|
||||
lastSampleAt: number | null;
|
||||
/** Retained scope evidence starts late or is absent, not a completeness guarantee. */
|
||||
@@ -89,11 +112,12 @@ export interface HeatmapCell extends Utilization {
|
||||
distinctDates: number;
|
||||
}
|
||||
|
||||
export interface PooledPressure {
|
||||
/** Share of known map-hours with every tracked lobby full, from 0 to 100. */
|
||||
export interface PooledAvailability {
|
||||
/** Share of known map-hours with no tracked lobby empty, from 0 to 100. */
|
||||
value: number | null;
|
||||
fullMapHours: number;
|
||||
noEmptyMapHours: number;
|
||||
knownMapHours: number;
|
||||
unknownMapHours: number;
|
||||
coveredMaps: number;
|
||||
totalMaps: number;
|
||||
}
|
||||
@@ -111,10 +135,11 @@ export interface MetricsData {
|
||||
scopeFirstSampleAt: number | null;
|
||||
scopeLatestSampleAt: number | null;
|
||||
summary: Utilization;
|
||||
running: RunningMetrics;
|
||||
servers: ServerMetrics[];
|
||||
maps: MapMetrics[];
|
||||
points: MetricPoint[];
|
||||
previous: PreviousMetrics;
|
||||
heatmap: { timeZone: string; cells: HeatmapCell[] };
|
||||
pooledPressure: PooledPressure;
|
||||
pooledAvailability: PooledAvailability;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 { installReportRevisions } from '../reports/revisions.js';
|
||||
import { 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';
|
||||
@@ -65,6 +65,11 @@ export const migrations: readonly Migration[] = [
|
||||
name: 'incremental vacuum page map',
|
||||
mode: 'autocommit',
|
||||
run: enableIncrementalVacuum
|
||||
},
|
||||
{
|
||||
version: 13,
|
||||
name: 'metrics policy report revisions',
|
||||
run: installMetricsPolicyRevisions
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
+122
-22
@@ -1,6 +1,7 @@
|
||||
import { fleetSettings } from '../settings.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { ONLINE_MAX_MS } from '../../server-freshness.js';
|
||||
import { zombieMaps } from '../../map-aliases.js';
|
||||
import {
|
||||
parseMetricsQuery,
|
||||
type HeatmapCell,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
type MetricsDays,
|
||||
type MetricsQuery,
|
||||
type PreviousMetrics,
|
||||
type RunningMetrics,
|
||||
type ServerMetrics,
|
||||
type Utilization
|
||||
} from '../../metrics.js';
|
||||
@@ -41,6 +43,18 @@ function emptyUtilization(): Utilization {
|
||||
return { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 };
|
||||
}
|
||||
|
||||
function emptyRunning(): RunningMetrics {
|
||||
return { average: null, peak: 0, capturedHours: 0 };
|
||||
}
|
||||
|
||||
function addRunning(total: RunningMetrics, count: number, hours: number): void {
|
||||
if (hours <= 0) return;
|
||||
const lobbyHours = (total.average ?? 0) * total.capturedHours + count * hours;
|
||||
total.capturedHours += hours;
|
||||
total.average = lobbyHours / total.capturedHours;
|
||||
total.peak = Math.max(total.peak, count);
|
||||
}
|
||||
|
||||
function addObservation(total: Utilization, players: number | null, hours: number): void {
|
||||
if (players === null) total.unknown += hours;
|
||||
else {
|
||||
@@ -71,6 +85,72 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
const since = now - days * 86400_000;
|
||||
const bucketMs = days === 1 ? 3600_000 : 86400_000;
|
||||
const handle = db();
|
||||
// Current settings are context for historical counts, never a reconstruction
|
||||
// of earlier policy revisions. Avoid loading autoscaling's mutation services.
|
||||
const globalPaused =
|
||||
(
|
||||
handle.prepare("SELECT value FROM settings WHERE key='autoscaling_paused'").get() as
|
||||
{ value: string } | undefined
|
||||
)?.value === '1';
|
||||
const policies = (
|
||||
handle
|
||||
.prepare(
|
||||
`SELECT id,game,map_slug,mode,settings,paused_reason,owns_map
|
||||
FROM autoscaling_policies ORDER BY owns_map DESC,map_slug,id`
|
||||
)
|
||||
.all() as {
|
||||
id: string;
|
||||
game: string;
|
||||
map_slug: string;
|
||||
mode: NonNullable<MapMetrics['policy']>['mode'];
|
||||
settings: string;
|
||||
paused_reason: string | null;
|
||||
owns_map: number;
|
||||
}[]
|
||||
).map((row) => {
|
||||
const { minimum, maximum, spares } = JSON.parse(row.settings) as {
|
||||
minimum: number;
|
||||
maximum: number;
|
||||
spares: number;
|
||||
};
|
||||
return {
|
||||
game: row.game,
|
||||
mapSlug: row.map_slug,
|
||||
ownsMap: row.owns_map === 1,
|
||||
context: {
|
||||
id: row.id,
|
||||
mode: row.mode,
|
||||
minimum,
|
||||
maximum,
|
||||
spares,
|
||||
pausedReason: globalPaused
|
||||
? ['Autoscaling is globally paused.', row.paused_reason]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: row.paused_reason
|
||||
}
|
||||
};
|
||||
});
|
||||
const policyFor = (game: string, label: string): MapMetrics['policy'] => {
|
||||
const normalized = label.toLowerCase();
|
||||
const catalog = zombieMaps(game);
|
||||
const named = catalog.find(
|
||||
(entry) => entry.friendly.toLowerCase() === normalized || entry.slug === normalized
|
||||
);
|
||||
// T6 variants share canonical names; do not choose a variant arbitrarily.
|
||||
const canonical = catalog.filter((entry) => entry.canonical === normalized);
|
||||
const slug = named?.slug ?? (canonical.length === 1 ? canonical[0].slug : null);
|
||||
if (!slug) return null;
|
||||
const applicable = policies.filter(
|
||||
(policy) => policy.game === game && (policy.mapSlug === slug || policy.mapSlug === '*')
|
||||
);
|
||||
return (
|
||||
applicable.find((policy) => policy.ownsMap)?.context ??
|
||||
applicable.find((policy) => policy.mapSlug === slug)?.context ??
|
||||
applicable[0]?.context ??
|
||||
null
|
||||
);
|
||||
};
|
||||
// These small grouped results keep options and retained evidence independent
|
||||
// of the chosen scope/window without transferring a second raw history scan.
|
||||
const groups = handle
|
||||
@@ -112,10 +192,12 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
maps: groups.map(({ game, map }) => ({ game, map }))
|
||||
};
|
||||
const summary = emptyUtilization();
|
||||
const running = emptyRunning();
|
||||
const previous: PreviousMetrics = {
|
||||
since: since - days * 86400_000,
|
||||
until: since,
|
||||
summary: emptyUtilization(),
|
||||
running: emptyRunning(),
|
||||
firstSampleAt: null,
|
||||
lastSampleAt: null,
|
||||
partialHistory: false
|
||||
@@ -131,6 +213,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
startAt: start,
|
||||
endAt: end,
|
||||
partial: start !== at || end !== at + bucketMs,
|
||||
capturedHours: 0,
|
||||
...emptyUtilization()
|
||||
});
|
||||
}
|
||||
@@ -211,6 +294,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
if (priorHours > 0) {
|
||||
previous.firstSampleAt ??= at;
|
||||
previous.lastSampleAt = at;
|
||||
addRunning(previous.running, samples.length, priorHours);
|
||||
for (const sample of samples)
|
||||
addObservation(previous.summary, sample.players, priorHours);
|
||||
}
|
||||
@@ -219,7 +303,11 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
if (at < since - ONLINE_MAX_MS) return;
|
||||
const start = Math.max(at, since);
|
||||
const hours = Math.max(0, end - start) / 3600_000;
|
||||
const pressure = new Map<MapMetrics, { known: boolean; full: boolean }>();
|
||||
addRunning(running, samples.length, hours);
|
||||
const availability = new Map<
|
||||
MapMetrics,
|
||||
{ known: boolean; empty: boolean; lobbies: number }
|
||||
>();
|
||||
const counts: BatchCounts = { known: 0, active: 0, idle: 0, unknown: 0, players: 0 };
|
||||
// Split once for the entire batch; observations crossing UTC hour/day
|
||||
// boundaries contribute to both buckets without shifting their duration.
|
||||
@@ -228,7 +316,9 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
const index = Math.floor((cursor - points[0].at) / bucketMs);
|
||||
const point = points[index];
|
||||
const stop = Math.min(end, point.endAt);
|
||||
slices.push({ point, hours: (stop - cursor) / 3600_000 });
|
||||
const sliceHours = (stop - cursor) / 3600_000;
|
||||
point.capturedHours += sliceHours;
|
||||
slices.push({ point, hours: sliceHours });
|
||||
cursor = stop;
|
||||
}
|
||||
for (const sample of samples) {
|
||||
@@ -251,8 +341,12 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
map = {
|
||||
game: sample.game,
|
||||
map: sample.map,
|
||||
full: 0,
|
||||
capacityObserved: 0,
|
||||
noEmpty: 0,
|
||||
availabilityObserved: 0,
|
||||
availabilityUnknown: 0,
|
||||
noEmptyAtMaximum: 0,
|
||||
noEmptyBelowMaximum: 0,
|
||||
policy: policyFor(sample.game, sample.map),
|
||||
...emptyUtilization()
|
||||
};
|
||||
game.set(sample.map, map);
|
||||
@@ -268,20 +362,24 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
if (sample.players > 0) counts.active++;
|
||||
else if (sample.players === 0) counts.idle++;
|
||||
}
|
||||
const known =
|
||||
sample.players !== null && sample.capacity !== null && sample.capacity > 0;
|
||||
const full = known && sample.players! >= sample.capacity!;
|
||||
const previous = pressure.get(map);
|
||||
pressure.set(map, {
|
||||
known: known && (previous?.known ?? true),
|
||||
full: full && (previous?.full ?? true)
|
||||
const prior = availability.get(map);
|
||||
availability.set(map, {
|
||||
known: sample.players !== null || (prior?.known ?? false),
|
||||
empty: sample.players === 0 || (prior?.empty ?? false),
|
||||
lobbies: (prior?.lobbies ?? 0) + 1
|
||||
});
|
||||
}
|
||||
for (const [map, state] of pressure) {
|
||||
for (const [map, state] of availability) {
|
||||
if (state.known) {
|
||||
map.capacityObserved += hours;
|
||||
if (state.full) map.full += hours;
|
||||
}
|
||||
map.availabilityObserved += hours;
|
||||
if (!state.empty) {
|
||||
map.noEmpty += hours;
|
||||
if (map.policy) {
|
||||
if (state.lobbies >= map.policy.maximum) map.noEmptyAtMaximum += hours;
|
||||
else map.noEmptyBelowMaximum += hours;
|
||||
}
|
||||
}
|
||||
} else map.availabilityUnknown += hours;
|
||||
}
|
||||
addHeatmap(counts, start, end);
|
||||
}
|
||||
@@ -328,13 +426,14 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
previous.firstSampleAt === null;
|
||||
for (let index = 0; index < cells.length; index++)
|
||||
cells[index].distinctDates = cellDates[index].size;
|
||||
const fullMapHours = maps.reduce((total, map) => total + map.full, 0);
|
||||
const knownMapHours = maps.reduce((total, map) => total + map.capacityObserved, 0);
|
||||
const pooledPressure = {
|
||||
value: knownMapHours > 0 ? (100 * fullMapHours) / knownMapHours : null,
|
||||
fullMapHours,
|
||||
const noEmptyMapHours = maps.reduce((total, map) => total + map.noEmpty, 0);
|
||||
const knownMapHours = maps.reduce((total, map) => total + map.availabilityObserved, 0);
|
||||
const pooledAvailability = {
|
||||
value: knownMapHours > 0 ? (100 * noEmptyMapHours) / knownMapHours : null,
|
||||
noEmptyMapHours,
|
||||
knownMapHours,
|
||||
coveredMaps: maps.filter((map) => map.capacityObserved > 0).length,
|
||||
unknownMapHours: maps.reduce((total, map) => total + map.availabilityUnknown, 0),
|
||||
coveredMaps: maps.filter((map) => map.availabilityObserved > 0).length,
|
||||
totalMaps: maps.length
|
||||
};
|
||||
return {
|
||||
@@ -345,11 +444,12 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
scope,
|
||||
scopeOptions,
|
||||
summary,
|
||||
running,
|
||||
servers,
|
||||
maps,
|
||||
points,
|
||||
previous,
|
||||
heatmap: { timeZone: scope.timeZone, cells },
|
||||
pooledPressure
|
||||
pooledAvailability
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
/** Current policy settings are inputs to historical metrics advice. */
|
||||
export function installMetricsPolicyRevisions(handle: Database.Database) {
|
||||
for (const table of ['autoscaling_policies', 'settings'])
|
||||
for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
|
||||
const versions =
|
||||
action === 'INSERT' ? ['NEW'] : action === 'DELETE' ? ['OLD'] : ['OLD', 'NEW'];
|
||||
const condition =
|
||||
table === 'settings'
|
||||
? `WHEN ${versions.map((row) => `${row}.key='autoscaling_paused'`).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;`);
|
||||
}
|
||||
}
|
||||
|
||||
export function installReportRevisions(handle: Database.Database) {
|
||||
handle.exec(`CREATE TABLE IF NOT EXISTS report_revisions (topic TEXT PRIMARY KEY, revision INTEGER NOT NULL DEFAULT 0);
|
||||
INSERT OR IGNORE INTO report_revisions(topic) VALUES ('metrics'), ('leaderboard');`);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { createTimeFormatter } from '$lib/time-zone.js';
|
||||
import {
|
||||
metricDuration,
|
||||
metricAverageLobbies,
|
||||
metricNumber,
|
||||
metricPercent,
|
||||
metricPeriodLabels
|
||||
@@ -21,9 +22,10 @@
|
||||
const contextSearch = $derived(
|
||||
(page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search
|
||||
);
|
||||
const mode = $derived(
|
||||
new URLSearchParams(contextSearch).get('chartMode') === 'hours' ? 'hours' : 'percent'
|
||||
);
|
||||
const mode = $derived.by(() => {
|
||||
const value = new URLSearchParams(contextSearch).get('chartMode');
|
||||
return value === 'hours' || value === 'percent' ? value : 'average';
|
||||
});
|
||||
const selectedIndex = $derived.by(() => {
|
||||
const existing = points.findIndex((point) => point.at === selectedAt);
|
||||
if (existing >= 0) return existing;
|
||||
@@ -34,7 +36,15 @@
|
||||
const selectedValue = $derived(selected ? String(selected.at) : '');
|
||||
const ceiling = $derived.by(() => {
|
||||
if (mode === 'percent') return 100;
|
||||
const max = Math.max(0, ...points.map((point) => point.observed + point.unknown));
|
||||
const max = Math.max(
|
||||
0,
|
||||
...points.map((point) =>
|
||||
mode === 'average'
|
||||
? (metricAverageLobbies(point.observed + point.unknown, point.capturedHours) ??
|
||||
0)
|
||||
: point.observed + point.unknown
|
||||
)
|
||||
);
|
||||
if (!max) return 1;
|
||||
const magnitude = 10 ** Math.floor(Math.log10(max));
|
||||
return Math.ceil(max / (magnitude / 2)) * (magnitude / 2);
|
||||
@@ -55,10 +65,10 @@
|
||||
const periodLabels = $derived(metricPeriodLabels(points, days, timeZone));
|
||||
const captured = $derived(selected ? selected.observed + selected.unknown : 0);
|
||||
|
||||
function setMode(value: 'hours' | 'percent') {
|
||||
function setMode(value: 'average' | 'hours' | 'percent') {
|
||||
const url = new URL(page.url);
|
||||
url.search = contextSearch;
|
||||
if (value === 'percent') url.searchParams.delete('chartMode');
|
||||
if (value === 'average') url.searchParams.delete('chartMode');
|
||||
else url.searchParams.set('chartMode', value);
|
||||
replaceState(url, { ...page.state, metricsTableSearch: url.search });
|
||||
}
|
||||
@@ -66,12 +76,27 @@
|
||||
selectedAt = at;
|
||||
const point = points.find((item) => item.at === at);
|
||||
if (!point) return;
|
||||
announcement = `${format.period(point.startAt)} to ${format.period(point.endAt)}${point.partial ? ', partial period' : ''}. ${point.observed + point.unknown ? `${metricDuration(point.active)} with players, ${metricDuration(point.idle)} idle, ${metricDuration(point.unknown)} unknown.` : 'No captured time.'}`;
|
||||
announcement = `${format.period(point.startAt)} to ${format.period(point.endAt)}${point.partial ? ', partial period' : ''}. ${pointSummary(point)}`;
|
||||
}
|
||||
function segmentHeight(point: MetricPoint, value: number) {
|
||||
const denominator = mode === 'percent' ? point.observed + point.unknown : ceiling;
|
||||
const denominator =
|
||||
mode === 'percent'
|
||||
? point.observed + point.unknown
|
||||
: mode === 'average'
|
||||
? ceiling * point.capturedHours
|
||||
: ceiling;
|
||||
return denominator > 0 ? (value / denominator) * 100 : 0;
|
||||
}
|
||||
function averageLabel(point: MetricPoint, value: number) {
|
||||
const average = metricAverageLobbies(value, point.capturedHours);
|
||||
return average === null ? '—' : metricNumber(average);
|
||||
}
|
||||
function pointSummary(point: MetricPoint) {
|
||||
if (point.observed + point.unknown === 0) return 'No captured time';
|
||||
return mode === 'average'
|
||||
? `${averageLabel(point, point.active)} with players · ${averageLabel(point, point.idle)} empty spares · ${averageLabel(point, point.unknown)} unknown on average`
|
||||
: `${metricDuration(point.active)} with players · ${metricDuration(point.idle)} empty spares · ${metricDuration(point.unknown)} unknown`;
|
||||
}
|
||||
function move(direction: number) {
|
||||
const next = points[Math.max(0, Math.min(points.length - 1, selectedIndex + direction))];
|
||||
if (next) selectPeriod(next.at);
|
||||
@@ -98,15 +123,22 @@
|
||||
<section class="panel" aria-labelledby="metrics-trend-title">
|
||||
<div class="heading">
|
||||
<div>
|
||||
<h2 id="metrics-trend-title">Server activity over time</h2>
|
||||
<h2 id="metrics-trend-title">Lobbies running over time</h2>
|
||||
<p>
|
||||
{mode === 'percent'
|
||||
? 'Share of captured server time, including unknown status.'
|
||||
: `Server-hours per ${days === 1 ? 'hour' : '24-hour period'}.`} Partial periods contain
|
||||
less elapsed time.
|
||||
{mode === 'average'
|
||||
? 'Average concurrent lobbies during captured time, including unknown status.'
|
||||
: mode === 'percent'
|
||||
? 'Share of captured lobby time, including unknown status.'
|
||||
: `Lobby-hours per ${days === 1 ? 'hour' : '24-hour period'}.`}
|
||||
Partial periods contain less elapsed time.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mode" role="group" aria-label="Timeline measure">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === 'average'}
|
||||
onclick={() => setMode('average')}>Average lobbies</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === 'percent'}
|
||||
@@ -117,12 +149,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span><i class="occupied"></i>With players</span><span><i class="idle"></i>Idle</span><span
|
||||
><i class="unknown"></i>Unknown</span
|
||||
><span class="partial-key">* Partial period</span>
|
||||
<span><i class="occupied"></i>With players</span><span><i class="idle"></i>Empty spare</span
|
||||
><span><i class="unknown"></i>Unknown</span><span class="partial-key">* Partial period</span
|
||||
>
|
||||
</div>
|
||||
<div class="axis-title">
|
||||
{mode === 'percent' ? 'Share of captured server time' : 'Server-hours'}
|
||||
{mode === 'average'
|
||||
? 'Average lobbies running'
|
||||
: mode === 'percent'
|
||||
? 'Share of captured lobby time'
|
||||
: 'Lobby-hours'}
|
||||
</div>
|
||||
<div class="chart-grid">
|
||||
<div class="y-axis" aria-hidden="true">
|
||||
@@ -133,7 +169,7 @@
|
||||
<div
|
||||
class="plot"
|
||||
role="group"
|
||||
aria-label="Server time by period. Select a bar or use the period selector below."
|
||||
aria-label="Lobbies by period. Select a bar or use the period selector below."
|
||||
>
|
||||
<div class="grid-lines" aria-hidden="true">
|
||||
{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}
|
||||
@@ -147,7 +183,7 @@
|
||||
type="button"
|
||||
tabindex={index === selectedIndex ? 0 : -1}
|
||||
aria-pressed={point.at === selected?.at}
|
||||
aria-label={`${format.period(point.startAt)}${point.partial ? ', partial period' : ''}: ${point.observed + point.unknown ? `${metricDuration(point.active)} with players, ${metricDuration(point.idle)} idle, ${metricDuration(point.unknown)} unknown` : 'No captured time'}`}
|
||||
aria-label={`${format.period(point.startAt)}${point.partial ? ', partial period' : ''}: ${pointSummary(point)}`}
|
||||
title={`${format.period(point.startAt)} – ${format.period(point.endAt)}${point.partial ? ' · partial period' : ''}`}
|
||||
onclick={() => selectPeriod(point.at)}
|
||||
onkeydown={(event) => chartKey(event, index)}
|
||||
@@ -192,8 +228,8 @@
|
||||
: ''}</time
|
||||
><span
|
||||
>{captured
|
||||
? `${metricDuration(selected.active)} with players · ${metricDuration(selected.idle)} idle · ${metricDuration(selected.unknown)} unknown · ${selected.observed ? metricNumber(selected.playerHours) : '—'} player-hours`
|
||||
: 'No captured time · not evidence of idle servers'}</span
|
||||
? `${pointSummary(selected)} · ${selected.observed ? metricNumber(selected.playerHours) : '—'} player-hours`
|
||||
: 'No captured time · not evidence of empty lobbies'}</span
|
||||
>
|
||||
</div>
|
||||
<details class="inspection">
|
||||
@@ -228,7 +264,7 @@
|
||||
</p>
|
||||
{#if captured === 0}<p class="empty">
|
||||
No captured time in this period. Missing observations do not mean the fleet
|
||||
was idle.
|
||||
had empty lobbies.
|
||||
</p>
|
||||
{:else}<dl>
|
||||
<div>
|
||||
@@ -243,7 +279,7 @@
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Idle</dt>
|
||||
<dt>Empty spare</dt>
|
||||
<dd>
|
||||
{metricDuration(selected.idle)}<small
|
||||
>{metricPercent(selected.idle, captured)} of captured time</small
|
||||
@@ -264,9 +300,10 @@
|
||||
</div>
|
||||
</dl>
|
||||
<p class="denominator">
|
||||
{metricDuration(captured)} captured server time · {metricDuration(
|
||||
{metricDuration(captured)} captured lobby time · {metricDuration(
|
||||
selected.observed
|
||||
)} known. Percentage bars include unknown time; the occupancy KPI excludes it.
|
||||
)} known over {metricDuration(selected.capturedHours)} of captured wall time.
|
||||
Averages divide lobby-hours by captured wall time. Collection gaps are excluded.
|
||||
</p>{/if}
|
||||
</div>
|
||||
</details>
|
||||
@@ -283,9 +320,11 @@
|
||||
>
|
||||
<thead
|
||||
><tr
|
||||
><th scope="col">Period</th><th scope="col">With players</th><th scope="col"
|
||||
>Idle</th
|
||||
><th scope="col">Unknown</th><th scope="col">Player-hours</th></tr
|
||||
><th scope="col">Period</th><th scope="col">Average lobbies</th><th
|
||||
scope="col">With players</th
|
||||
><th scope="col">Empty spare</th><th scope="col">Unknown</th><th scope="col"
|
||||
>Player-hours</th
|
||||
></tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
@@ -296,11 +335,11 @@
|
||||
? ' · Partial'
|
||||
: ''}</small
|
||||
></th
|
||||
><td>{metricDuration(point.active)}</td><td
|
||||
>{metricDuration(point.idle)}</td
|
||||
><td>{metricDuration(point.unknown)}</td><td
|
||||
>{point.observed ? metricNumber(point.playerHours) : '—'}</td
|
||||
></tr
|
||||
><td>{averageLabel(point, point.observed + point.unknown)}</td><td
|
||||
>{metricDuration(point.active)}</td
|
||||
><td>{metricDuration(point.idle)}</td><td
|
||||
>{metricDuration(point.unknown)}</td
|
||||
><td>{point.observed ? metricNumber(point.playerHours) : '—'}</td></tr
|
||||
>{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
|
||||
@@ -5,14 +5,12 @@
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import type { MetricsData } from '$lib/metrics.js';
|
||||
import { HOSTING_RULES, metricRecommendations } from '$lib/metrics-insights.js';
|
||||
import { POLICY_SIGNAL_RULES, metricRecommendations } from '$lib/metrics-insights.js';
|
||||
let { report }: { report: MetricsData } = $props();
|
||||
const recommendations = $derived(metricRecommendations(report.maps));
|
||||
const highlighted = $derived(
|
||||
recommendations
|
||||
.filter(
|
||||
(item) => !item.tentative && (item.kind === 'expand' || item.kind === 'consolidate')
|
||||
)
|
||||
.filter((item) => !item.tentative && item.kind !== 'watch' && item.kind !== 'reporting')
|
||||
.slice(0, 3)
|
||||
);
|
||||
const remaining = $derived(recommendations.filter((item) => !highlighted.includes(item)));
|
||||
@@ -49,6 +47,7 @@
|
||||
? ' · Limited evidence'
|
||||
: ''}</small
|
||||
>
|
||||
{#if suggestion.policySummary}<small>{suggestion.policySummary}</small>{/if}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{suggestion.title}</strong>
|
||||
@@ -58,17 +57,24 @@
|
||||
<p>{suggestion.reason}</p>
|
||||
<p>{suggestion.outcome}</p>
|
||||
</details>
|
||||
{#if suggestion.href && suggestion.action}
|
||||
<a class="policy-link" href={suggestion.href}>{suggestion.action}</a>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/snippet}
|
||||
<section class="explore" aria-labelledby="metrics-explore-title">
|
||||
<h2 id="metrics-explore-title">Hosting insights</h2>
|
||||
<p>Historical signals for hosting trials. Check the current lineup before making a change.</p>
|
||||
<h2 id="metrics-explore-title">Autoscaling signals</h2>
|
||||
<p>
|
||||
Compare recorded empty-lobby availability with each map’s current policy. Current settings
|
||||
are applied to historical lobby counts; these are not records of the policy active then. No
|
||||
reported empty lobby does not prove that players were turned away.
|
||||
</p>
|
||||
{#if highlighted.length}{#each highlighted as suggestion}{@render finding(suggestion)}{/each}
|
||||
{:else}<p class="empty">No hosting trials meet the evidence threshold in this period.</p>{/if}
|
||||
{:else}<p class="empty">No policy changes meet the evidence threshold in this period.</p>{/if}
|
||||
{#if limited}<p class="notice">
|
||||
{limited} of {recommendations.length} maps have limited evidence. Review occupancy reporting,
|
||||
player limits, and observation duration before changing hosting.
|
||||
{limited} of {recommendations.length} maps have limited evidence. Review occupancy reporting
|
||||
and observation duration before changing a policy.
|
||||
</p>{/if}
|
||||
{#if remaining.length}<details class="remaining">
|
||||
<summary>Other findings ({remaining.length})</summary>
|
||||
@@ -104,31 +110,33 @@
|
||||
</p>{/each}
|
||||
</details>{/if}
|
||||
<details class="rules">
|
||||
<summary>How Rules v1 chooses a suggestion</summary>
|
||||
<summary>How policy signals are chosen</summary>
|
||||
<ul>
|
||||
<li>
|
||||
Trial an additional lobby when all tracked lobbies are full for at least {HOSTING_RULES.pressureExpansion *
|
||||
100}% of known-capacity time.
|
||||
Review a policy when no empty lobby was reported for at least {POLICY_SIGNAL_RULES.noEmptyShare *
|
||||
100}% of known map time. When most of that time had a lobby count at or above
|
||||
the current maximum, consider raising the maximum. Otherwise, review the spare
|
||||
target and create time. Equal durations favor reviewing the maximum.
|
||||
</li>
|
||||
<li>
|
||||
When capacity is unavailable, at least {HOSTING_RULES.occupancyExpansion * 100}%
|
||||
occupied known time can suggest a tentative additional-lobby trial. Occupancy does
|
||||
not establish saturation.
|
||||
An empty lobby has a reported player count of zero. When some lobbies report players
|
||||
and the rest are unknown, none reports empty; unknown lobbies may still have been
|
||||
empty. Player limits are not needed for these signals.
|
||||
</li>
|
||||
<li>
|
||||
Review idle hosting when occupied known time is at most {HOSTING_RULES.idleUtilization *
|
||||
100}%, and either measured pressure is below {HOSTING_RULES.quietPressure *
|
||||
100}% or capacity is unavailable.
|
||||
Maps without a current policy suggest adding one. Paused or disabled policies prompt
|
||||
a review before resuming or enabling them.
|
||||
</li>
|
||||
<li>
|
||||
Missing occupancy prompts a reporting check. Other cases suggest watching hosting or
|
||||
checking capacity reporting.
|
||||
All-unknown occupancy prompts a reporting check. Other cases suggest watching
|
||||
empty-lobby availability.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Less than {HOSTING_RULES.minimumEvidenceHours} hour of relevant known time, missing capacity,
|
||||
or substantial unknown status marks advice as tentative. These are trial policies, not validated
|
||||
demand forecasts. Collection gaps are omitted from all rules.
|
||||
Less than {POLICY_SIGNAL_RULES.minimumEvidenceHours} hour of known map availability, or less
|
||||
than {POLICY_SIGNAL_RULES.minimumKnownShare * 100}% known map availability or lobby
|
||||
occupancy, marks a signal as tentative. These signals are advisory, not demand
|
||||
forecasts. Collection gaps are omitted from all rules.
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
@@ -185,6 +193,11 @@
|
||||
border-left: 2px solid var(--state-warning);
|
||||
padding-left: 12px;
|
||||
}
|
||||
.policy-link {
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
li {
|
||||
margin: 8px 0;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { MetricsData } from '$lib/metrics.js';
|
||||
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
|
||||
import { countLabel, plural } from '$ui/plural.js';
|
||||
import Card from '$ui/components/primitives/Card.svelte';
|
||||
import Stat from '$ui/components/primitives/Stat.svelte';
|
||||
let { report }: { report: MetricsData } = $props();
|
||||
const prior = $derived(report.previous);
|
||||
const pressure = $derived(report.pooledPressure);
|
||||
const availability = $derived(report.pooledAvailability);
|
||||
const captured = $derived(report.summary.observed + report.summary.unknown);
|
||||
const comparable = $derived(report.summary.observed > 0 && prior.summary.observed > 0);
|
||||
const occupancyDelta = $derived(
|
||||
comparable
|
||||
? 100 *
|
||||
(report.summary.active / report.summary.observed -
|
||||
prior.summary.active / prior.summary.observed)
|
||||
: null
|
||||
);
|
||||
const playerDelta = $derived(
|
||||
comparable && prior.summary.playerHours > 0
|
||||
? 100 * (report.summary.playerHours / prior.summary.playerHours - 1)
|
||||
@@ -32,19 +27,21 @@
|
||||
<div class="summary">
|
||||
<Card
|
||||
><Stat
|
||||
label="Time with players"
|
||||
value={metricPercent(report.summary.active, report.summary.observed)}
|
||||
label="Lobbies running"
|
||||
value={report.running.average === null
|
||||
? '—'
|
||||
: `${metricNumber(report.running.average)} avg`}
|
||||
size="lg"
|
||||
tween={false}
|
||||
/>
|
||||
<p>
|
||||
{occupancyDelta === null
|
||||
? 'Comparison unavailable'
|
||||
: `${signed(occupancyDelta, ' percentage points')} vs ${previousLabel}`}
|
||||
{report.running.average === null
|
||||
? 'Peak unavailable'
|
||||
: `Peak ${countLabel(report.running.peak, 'lobby', 'lobbies')} at once`}
|
||||
</p>
|
||||
<small
|
||||
>{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} observed
|
||||
server time</small
|
||||
>{metricDuration(report.summary.idle)} of {metricDuration(captured)} captured lobby-hours
|
||||
were empty spares ({metricPercent(report.summary.idle, captured)})</small
|
||||
>
|
||||
</Card>
|
||||
<Card
|
||||
@@ -68,15 +65,15 @@
|
||||
</Card>
|
||||
<Card
|
||||
><Stat
|
||||
label="Time all lobbies were full"
|
||||
value={metricPercent(pressure.fullMapHours, pressure.knownMapHours)}
|
||||
label="Time with no empty lobby"
|
||||
value={metricPercent(availability.noEmptyMapHours, availability.knownMapHours)}
|
||||
size="lg"
|
||||
tween={false}
|
||||
/>
|
||||
<p>
|
||||
{pressure.knownMapHours > 0
|
||||
? `${pressure.coveredMaps} of ${pressure.totalMaps} maps have capacity data`
|
||||
: 'Capacity data unavailable'}
|
||||
{availability.knownMapHours > 0
|
||||
? `${availability.coveredMaps} of ${countLabel(availability.totalMaps, 'map')} ${plural(availability.totalMaps, 'has', 'have')} availability data`
|
||||
: 'Lobby availability unavailable'}
|
||||
</p>
|
||||
<small>Combined map history · each map measured separately</small>
|
||||
</Card>
|
||||
@@ -84,13 +81,9 @@
|
||||
{#if prior.partialHistory}<p class="notice">
|
||||
Partial previous history. Comparisons use available observations without extrapolation.
|
||||
</p>{/if}
|
||||
{#if report.maps.some((map) => map.capacityObserved <= 0)}<p class="notice">
|
||||
{report.maps.filter((map) => map.capacityObserved <= 0).length} of {report.maps.length} maps lack
|
||||
capacity data. Player limits are needed to measure full lobbies; occupancy can still be recorded.
|
||||
</p>{/if}
|
||||
{#if report.summary.unknown > 0}<p class="quality">
|
||||
{metricDuration(report.summary.unknown)} of captured status is unknown and excluded from “Time
|
||||
with players.” Collection gaps are separate.
|
||||
{metricDuration(report.summary.unknown)} of captured lobby time had unknown status. Unknown lobbies
|
||||
are included in running counts and excluded from empty spares. Collection gaps are separate.
|
||||
</p>{/if}
|
||||
|
||||
<style>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
{ key: 'map', label: 'Map' },
|
||||
{ key: 'playerHours', label: 'Player-hours' },
|
||||
{ key: 'active', label: 'With players' },
|
||||
{ key: 'full', label: 'All lobbies full' },
|
||||
{ key: 'noEmpty', label: 'No empty lobby' },
|
||||
{ key: 'details', label: 'Details' }
|
||||
] as const;
|
||||
const serverColumns = [
|
||||
@@ -109,7 +109,8 @@
|
||||
}
|
||||
function mapValue(map: MapMetrics, key: Exclude<MapSort, 'map'>): number | null {
|
||||
if (key === 'details') return null;
|
||||
if (key === 'full') return map.capacityObserved ? map.full / map.capacityObserved : null;
|
||||
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 {
|
||||
@@ -159,7 +160,7 @@
|
||||
|
||||
<section class="panel" aria-label={view === 'maps' ? 'Map metrics' : 'Instance metrics'}>
|
||||
<div class="heading">
|
||||
<h2>{view === 'maps' ? 'Map play time and capacity' : 'Instance utilization'}</h2>
|
||||
<h2>{view === 'maps' ? 'Map play time and lobby availability' : 'Instance utilization'}</h2>
|
||||
<div class="search">
|
||||
<Field label={view === 'maps' ? 'Find a map' : 'Find an instance'}
|
||||
><Input
|
||||
@@ -225,8 +226,9 @@
|
||||
<th scope="row"
|
||||
>{map.map}<small
|
||||
>{gameName(map.game)}{map.observed < 1 ||
|
||||
map.capacityObserved < 1 ||
|
||||
map.unknown > map.observed / 4
|
||||
map.availabilityObserved < 1 ||
|
||||
map.unknown > map.observed / 4 ||
|
||||
map.availabilityUnknown > map.availabilityObserved / 4
|
||||
? ' · Limited evidence'
|
||||
: ''}</small
|
||||
></th
|
||||
@@ -240,23 +242,25 @@
|
||||
</div></td
|
||||
>
|
||||
<td>{map.observed ? metricDuration(map.active) : '—'}</td>
|
||||
<td>{metricPercent(map.full, map.capacityObserved)}</td>
|
||||
<td>{metricPercent(map.noEmpty, map.availabilityObserved)}</td>
|
||||
<td
|
||||
><details>
|
||||
<summary
|
||||
><span class="sr-only">{map.map} </span>Details</summary
|
||||
>
|
||||
<dl>
|
||||
<dt>Observed instance time</dt>
|
||||
<dt>Observed lobby time</dt>
|
||||
<dd>{metricDuration(map.observed)}</dd>
|
||||
<dt>Unknown status</dt>
|
||||
<dd>{metricDuration(map.unknown)}</dd>
|
||||
<dt>All lobbies full</dt>
|
||||
<dt>No empty lobby</dt>
|
||||
<dd>
|
||||
{map.capacityObserved
|
||||
? `${metricDuration(map.full)} of ${metricDuration(map.capacityObserved)} known map time`
|
||||
: 'Capacity data unavailable'}
|
||||
{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
|
||||
>
|
||||
@@ -289,7 +293,7 @@
|
||||
<dd><code>{server.server}</code></dd>
|
||||
<dt>Time with players</dt>
|
||||
<dd>{metricDuration(server.active)}</dd>
|
||||
<dt>Idle</dt>
|
||||
<dt>Empty spare</dt>
|
||||
<dd>
|
||||
{metricPercent(server.idle, server.observed)} · {metricDuration(
|
||||
server.idle
|
||||
@@ -338,7 +342,7 @@
|
||||
<summary>About these metrics</summary>
|
||||
<p>
|
||||
{view === 'maps'
|
||||
? 'All lobbies full uses map time when every tracked lobby has known occupancy and capacity. It does not measure failed joins or unmet demand. A dash means unavailable, not zero.'
|
||||
? '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>
|
||||
|
||||
@@ -5,12 +5,25 @@ const record = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
const numeric = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value);
|
||||
const nonnegative = (value: unknown): value is number => numeric(value) && value >= 0;
|
||||
const timestamp = (value: unknown) => value === null || numeric(value);
|
||||
const totals = (value: unknown) =>
|
||||
record(value) &&
|
||||
['observed', 'active', 'idle', 'unknown', 'playerHours'].every(
|
||||
(key) => numeric(value[key]) && value[key] >= 0
|
||||
);
|
||||
const running = (value: unknown) =>
|
||||
record(value) &&
|
||||
(value.average === null || nonnegative(value.average)) &&
|
||||
nonnegative(value.peak) &&
|
||||
nonnegative(value.capturedHours);
|
||||
const policy = (value: unknown) =>
|
||||
value === null ||
|
||||
(record(value) &&
|
||||
typeof value.id === 'string' &&
|
||||
['off', 'create-destroy'].includes(value.mode as string) &&
|
||||
['minimum', 'maximum', 'spares'].every((key) => nonnegative(value[key])) &&
|
||||
(value.pausedReason === null || typeof value.pausedReason === 'string'));
|
||||
|
||||
function validReport(value: unknown): value is MetricsData {
|
||||
if (
|
||||
@@ -18,7 +31,8 @@ function validReport(value: unknown): value is MetricsData {
|
||||
![1, 7, 30].includes(value.days as number) ||
|
||||
!numeric(value.now) ||
|
||||
!numeric(value.since) ||
|
||||
!totals(value.summary)
|
||||
!totals(value.summary) ||
|
||||
!running(value.running)
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
@@ -58,6 +72,7 @@ function validReport(value: unknown): value is MetricsData {
|
||||
numeric(item.at) &&
|
||||
numeric(item.startAt) &&
|
||||
numeric(item.endAt) &&
|
||||
nonnegative(item.capturedHours) &&
|
||||
typeof item.partial === 'boolean'
|
||||
)
|
||||
)
|
||||
@@ -70,8 +85,14 @@ function validReport(value: unknown): value is MetricsData {
|
||||
totals(item) &&
|
||||
typeof item.game === 'string' &&
|
||||
typeof item.map === 'string' &&
|
||||
numeric(item.full) &&
|
||||
numeric(item.capacityObserved)
|
||||
[
|
||||
'noEmpty',
|
||||
'availabilityObserved',
|
||||
'availabilityUnknown',
|
||||
'noEmptyAtMaximum',
|
||||
'noEmptyBelowMaximum'
|
||||
].every((key) => nonnegative(item[key])) &&
|
||||
policy(item.policy)
|
||||
)
|
||||
)
|
||||
return false;
|
||||
@@ -89,6 +110,7 @@ function validReport(value: unknown): value is MetricsData {
|
||||
if (
|
||||
!record(value.previous) ||
|
||||
!totals(value.previous.summary) ||
|
||||
!running(value.previous.running) ||
|
||||
!numeric(value.previous.since) ||
|
||||
!numeric(value.previous.until) ||
|
||||
!timestamp(value.previous.firstSampleAt) ||
|
||||
@@ -111,10 +133,10 @@ function validReport(value: unknown): value is MetricsData {
|
||||
)
|
||||
return false;
|
||||
return (
|
||||
record(value.pooledPressure) &&
|
||||
(value.pooledPressure.value === null || numeric(value.pooledPressure.value)) &&
|
||||
['fullMapHours', 'knownMapHours', 'coveredMaps', 'totalMaps'].every((key) =>
|
||||
numeric((value.pooledPressure as Record<string, unknown>)[key])
|
||||
record(value.pooledAvailability) &&
|
||||
(value.pooledAvailability.value === null || nonnegative(value.pooledAvailability.value)) &&
|
||||
['noEmptyMapHours', 'knownMapHours', 'unknownMapHours', 'coveredMaps', 'totalMaps'].every(
|
||||
(key) => nonnegative((value.pooledAvailability as Record<string, unknown>)[key])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,13 @@ export function metricNumber(value: number): string {
|
||||
return numberFormat.format(value);
|
||||
}
|
||||
|
||||
/** Normalize lobby-hours by captured wall time so partial buckets remain comparable. */
|
||||
export function metricAverageLobbies(lobbyHours: number, capturedHours: number): number | null {
|
||||
if (!Number.isFinite(lobbyHours) || !Number.isFinite(capturedHours) || capturedHours <= 0)
|
||||
return null;
|
||||
return lobbyHours / capturedHours;
|
||||
}
|
||||
|
||||
export function metricPercent(value: number, total: number): string {
|
||||
if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return '—';
|
||||
const percent = (100 * value) / total;
|
||||
|
||||
Reference in New Issue
Block a user