Make metrics reflect autoscaling policies (Refs #137)
CI / checks (push) Successful in 4m49s

This commit is contained in:
2026-10-10 14:48:52 -04:00
parent bd34c3c9e7
commit 33da4482a6
24 changed files with 1264 additions and 370 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ snapshots naturally show stale observations when viewed against today's clock.
The fixture contains 3 hosts, 12 canonical Black Ops I/II servers, and 500 match The fixture contains 3 hosts, 12 canonical Black Ops I/II servers, and 500 match
records: 494 historical and 6 live. It includes solo through four-player squads, records: 494 historical and 6 live. It includes solo through four-player squads,
unknown starting counts, enough players and matches to exercise pagination, unknown starting counts, enough players and matches to exercise pagination,
rosters, seven days of utilization and capacity samples, and retained Activity rosters, seven days of occupancy samples with unknown player limits, and retained Activity
from campaigns, betas, host operations, maintenance, recovery, and autoscaling. from campaigns, betas, host operations, maintenance, recovery, and autoscaling.
The fleet has 6 live matches, 2 empty lobbies, a preparing server, and 3 problems: The fleet has 6 live matches, 2 empty lobbies, a preparing server, and 3 problems:
a stopped server, a server not responding, and a server on the offline host. a stopped server, a server not responding, and a server on the offline host.
+1 -1
View File
@@ -73,7 +73,7 @@ The production observation period and live pilot have not been run as part of im
The page retains up to 31 days of decision evidence and observations, with the 30 most recent decision changes shown. Repeated unchanged evaluations update the decision's last-seen time instead of adding a row. Unresolved intent survives retention. Provisioning history retains the operation and policy backlink separately. The page retains up to 31 days of decision evidence and observations, with the 30 most recent decision changes shown. Repeated unchanged evaluations update the decision's last-seen time instead of adding a row. Unresolved intent survives retention. Provisioning history retains the operation and policy backlink separately.
Measurement totals exclude collection gaps. Request-to-verified-readiness summaries include queue time and only completed creates; they do not establish browser visibility or successful joins. “Time with players” in Metrics can rise simply because idle lobbies leave its denominator. Use host CPU and memory measurements alongside player-hours and spare availability when assessing savings. Measurement totals exclude collection gaps. Request-to-verified-readiness summaries include queue time and only completed creates; they do not establish browser visibility or successful joins. [Metrics](metrics.md) shows average and peak lobby counts, empty spare hours, and map time with no reported empty lobby. Its autoscaling signals compare historical counts with current policy targets. Use host CPU and memory measurements alongside player-hours and spare availability when assessing savings.
## API and implementation ## API and implementation
+108 -69
View File
@@ -5,98 +5,137 @@ Choose the past 24 hours, 7 days, or 30 days. The page refreshes every minute.
## Overview and filters ## Overview and filters
The overview shows **Time with players** (occupied server-hours divided by known **Lobbies running** shows the average and peak number of recorded lobbies in the
server-hours) and **Player-hours**. Known and unknown status time remain visible selected scope. The average divides captured lobby-hours, including unknown
alongside the totals. **Last collected** is the newest recorded batch, including status, by captured wall time. Empty spares are deliberate supply for new groups:
unknown checks; **Report refreshed** is when this report was calculated. Collection the card shows their hours and share of all captured lobby-hours, rather than
stops when no servers are registered. Earliest retained observations do not prove treating them as failed utilization. Unknown status can include lobbies starting
continuous history or the original installation date. or unavailable, so this count does not guarantee every lobby was ready to join.
**Player-hours** integrates reported player counts over time. **No empty lobby**
shows the share of known map time when no tracked lobby reported zero players.
Each game/map is measured separately, then its durations are pooled. This is not
elapsed fleet time or a count of rejected joins. A partly occupied Zombies lobby
does not count as an empty spare, even if player slots remain.
**Last collected** is the newest recorded batch, including unknown checks;
**Report refreshed** is when this report was calculated. Collection stops when no
servers are registered. Earliest retained observations do not prove continuous
history or the original installation date.
Background refresh keeps the last successful report visible on failure and offers Background refresh keeps the last successful report visible on failure and offers
Retry. Returning to a visible tab refreshes immediately. The selected range is Retry. Returning to a visible tab refreshes immediately. Game and map filters apply
stored in the URL, and refreshes preserve table controls and chart selection. to the whole report, including comparisons, signals, and the heatmap. Map choices
preserve the exact recorded game/map pair. Range and scope are shareable through
Game and map filters apply to the whole report, including its comparisons, the URL; refreshes preserve table controls and chart selection.
recommendations, and heatmap. Map choices preserve the exact recorded game/map
pair. Range and scope are shareable through the URL. Table searches remain local.
## Timeline and tables ## Timeline and tables
The timeline defaults to **Share of captured time**: each stack includes occupied, The timeline defaults to **Average lobbies running**, split into **With players**,
idle, and unknown server time, normalized to 100%. This denominator differs from the **Empty spare**, and **Unknown**. Each bucket divides those lobby-hours by its
headline utilization percentage, which excludes unknown time. Switch to **Hours** captured wall time, so changing lobby counts remain visible. Collection gaps are
to compare absolute server time. Periods without captured time remain gaps. excluded from that denominator. Switch to the share view to compare proportions,
The timeline has labelled axes and marks partial edge periods. Select a period, or hours to compare absolute lobby time. Periods without captured time remain gaps.
use Previous/Next, or select a bar to inspect its exact interval and values. The
time table supplies the same figures for comparison and assistive technology.
Zero captured time is distinct from an observed empty server. Short durations use
seconds or minutes so a real observation is not rounded to zero.
Map and server tables support numeric sorting and local search. Their searches The timeline has labelled axes and marks partial edge periods. Select a bar or use
apply only to the named table. Known-capacity duration stays next to all-full time. Previous/Next to inspect its exact interval and values. The time table supplies
Server identities retain the technical name; rows describe history grouped by the same figures for comparison and assistive technology. Zero captured time is
name, which may include different instances if a name was reused. **Open current distinct from an observed empty lobby. Short durations use seconds or minutes so
server** appears only when that name is currently registered; its destination a real observation is not rounded to zero.
represents the current registration rather than every instance in the history.
## Comparisons and hosting experiments Map and instance tables support numeric sorting and local search. The map table's
**No empty lobby** column uses occupancy alone and retains the known map-time
denominator in its details. Unknown map availability stays distinct from zero
time without a spare. Evidence can be limited by short observations or unknown
occupancy, never by missing player limits.
**Explore this period** adds experimental decision aids below the main timeline: Instance rows describe history grouped by server name, which can include different
instances if a name was reused. **Open current server** appears only when that name
is currently registered; its destination represents the current registration.
- **Map capacity pressure** pools all-full map-hours divided by known-capacity ## Comparisons and activity
map-hours across the selected map groups. Overlapping map intervals are counted
separately. The score is not elapsed fleet time or a fleet health score; its
denominator and contributing map count remain visible.
- **Previous-period comparisons** use the preceding equally long window with
identical filters. Utilization changes are percentage points; player-hours
changes are relative percentages. A previous observed zero can show **New**.
Missing evidence remains unavailable. Partial retained history is labelled;
a 30-day comparison usually has little previous history because default retention is
31 days. Both periods' known time is shown. Bounds cannot establish that
collection was uninterrupted, and changes are not adjusted for fleet size.
- **Activity heatmap** groups captured time by weekday and hour in its displayed
timezone. Color represents occupied / known server time. Exact cell values
include unknown time and contributing dates. No-data cells differ from observed
zero utilization; repeated DST hours contribute their actual elapsed time.
- **Hosting experiments** are generated automatically from the selected map
observations. They are advisory and never create, restart, or destroy a server.
Hosting Rules v1 deliberately offer experiments even with small samples: Previous-period comparisons use the preceding equally long window with identical
filters. Average lobby counts compare recorded supply, while player-hours changes
are relative percentages. A previous observed zero can show **New**. Missing
evidence remains unavailable. Partial retained history is labelled; a 30-day
comparison usually has little previous history because default retention is
31 days. Collection gaps are excluded, and comparisons do not prove uninterrupted
collection or adjust player-hours for fleet size.
| Recorded condition | Suggested experiment | The activity heatmap groups captured time by weekday and hour in its displayed
| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | timezone. Color represents occupied / known lobby time. Exact cell values include
| At least 20% of known capacity time had every lobby full | Trial one additional lobby during busy hours. | unknown time and contributing dates. No-data cells differ from observed zero;
| Capacity unavailable and players present for at least 80% of known server time | Tentatively trial an extra lobby using occupancy as a proxy. | repeated DST hours contribute their actual elapsed time. Occupancy share describes
| Players present for at most 10% of known time, with under 1% measured pressure or unavailable capacity | Review one idle lobby or a shorter hosting window while preserving access to the map. | how the fleet was used, but can change simply because a policy adds or removes
| Occupancy unavailable | Check telemetry/RCON reporting and watch for known player counts. | spares. Read it alongside player-hours, lobby counts, and empty-lobby availability.
| Other observations | Check missing capacity or keep the lineup and watch peaks. |
Each suggestion includes evidence and an outcome to watch. Unknown capacity, ## Autoscaling signals
less than one hour of relevant known evidence, or substantial unknown captured
status makes the suggestion tentative. These thresholds are product hypotheses; Signals compare each map's observations with its **current** applicable policy.
occupancy does not establish failed joins or a need for additional capacity. Game-wide policies apply independently to each supported map variant. Each signal
Suggestions use historical observations. Check the current lineup before trying links to the policy on **Fleet → Autoscaling**; a map without a policy gets a link
a change; a retained map may no longer have a hosted lobby. to add one. Signals are advisory and never change a policy or a lobby.
| Recorded condition | Suggested action |
| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| No empty lobby for at least 2% of known map time, mostly at or above the current maximum | Review raising the policy maximum. |
| No empty lobby for at least 2% of known map time, mostly below the current maximum | Review the spare target and check creation time or blockers. |
| An applicable policy is off or paused | Review the policy and its pause or activation conditions. |
| No applicable policy and known occupancy is available | Consider adding an autoscaling policy to keep spares available. |
| Occupancy unavailable | Check telemetry/RCON reporting and wait for known player counts. |
| No shortage signal | Keep watching empty-lobby availability. |
Evidence includes no-empty map time and how it splits above/below the current
maximum. Less than one hour of known map availability, less than 80% known map
availability, or less than 80% known lobby time makes advice tentative. Player limits are not needed. These
thresholds are advisory rules, not demand forecasts.
Policy settings may have changed during the selected period. Comparing historical
counts against today's maximum does not establish which settings applied then or
whether autoscaling was enabled. No reported empty lobby also does not prove
failed joins or unmet demand. Review provisioning delays, blockers, host resources,
and game-key availability before changing targets.
## Definitions and collection ## Definitions and collection
- Utilized means a successful status response reported at least one player. - **With players:** a successful observation reported at least one player.
- Idle means a successful response reported zero players. - **Empty spare:** an observation reported zero players.
- Unknown covers failed checks, provisioning, and unavailable containers; it is excluded from utilization percentages. - **Unknown:** failed checks, provisioning, or otherwise unavailable occupancy.
- Player-hours integrate the reported player count over observed time. Bots are included if the server counts them as players. - **Player-hours:** reported players × observed time. Bots count if the server includes them.
- All lobbies full measures map wall time when every tracked lobby in that game/map group has a known capacity and is full. Missing capacity or status makes that interval unknown. This is a capacity-pressure indicator, not a count of rejected joins or unmet demand. - **Empty lobby available:** at least one lobby on the game/map reported zero players.
- **No empty lobby:** at least one lobby reported occupancy, and none reported zero. Mixed positive and unknown counts mean no empty lobby was reported; an unknown lobby might actually have been empty.
- **Unknown map availability:** every lobby on the game/map had unknown occupancy. This interval is excluded from the known-map denominator and is never counted as a spare shortage.
Observations are recorded by the existing status worker. Each remains valid until the next collection, up to the 90-second freshness limit. Longer manager outages are excluded. Charts split time at UTC hour/day boundaries; partial periods remain partial. Map variants use the configured variant when it matches the reported map. Observations come from the existing status worker. Each complete collection stays
valid until the next collection, up to the 90-second freshness limit. Longer gaps
are excluded, not counted as unknown or zero lobbies. Captured wall time includes
intervals with at least one recorded lobby in the selected scope. Unknown lobby
time and unknown map time use different units: several unknown lobbies on one map
contribute several lobby-hours but only one interval of unknown map availability.
Capacity comes from `sv_maxclients` / `sv_maxClients` in the status response. When unavailable, the UI displays unknown rather than assuming a four-player limit. Capacity coverage is the map time for which every tracked lobby reported both occupancy and capacity. Charts split time at UTC hour/day boundaries; partial periods remain partial.
Map variants use the configured variant when it matches the reported map. Player
capacity may be retained in raw samples, but no metric or signal requires
`sv_maxclients` or an assumed four-player limit.
Collection begins after this feature is installed; previous occupancy cannot be reconstructed. Samples default to 31 days of retention; Admin → Settings can change this period. Cleanup runs during collection and in the background. They survive removal of a server and contain no player identities. The additive database table is created automatically on startup. Collection begins after this feature is installed; earlier occupancy cannot be
reconstructed. Samples default to 31 days of retention; **Admin → Settings** can
change this period. Cleanup runs during collection and in the background. Samples
survive removal of a server and contain no player identities.
## API ## API
`GET /api/metrics` supplies the same report as the page and disables response `GET /api/metrics` supplies the same report as the page and disables HTTP response
caching. Query parameters are `days` (1, 7, or 30), `game`, `map` (requires game), caching. Query parameters are `days` (1, 7, or 30), `game`, `map` (requires game),
and `timeZone` (an IANA zone). Invalid ranges and zones fall back to the defaults; and `timeZone` (an IANA zone). Invalid ranges and zones fall back to the defaults;
invalid scope syntax is normalized. Fleet collection timestamps remain separate invalid scope syntax is normalized. Fleet collection timestamps remain separate
from selected-scope evidence timestamps. Collection boundaries are determined from selected-scope evidence timestamps. Collection boundaries are determined
before scope filtering, so samples from other groups still end a batch interval. before scope filtering, so samples from other groups still end a batch interval.
`running` contains `average`, `peak`, and `capturedHours`; timeline points include
their own `capturedHours`. Map rows expose `noEmpty`, `availabilityObserved`, and
`availabilityUnknown` durations, plus current `policy` context and
`noEmptyAtMaximum` / `noEmptyBelowMaximum` durations. `pooledAvailability` combines
map durations and coverage. Policy edits and global pause changes invalidate
cached metrics reports.
+1 -1
View File
@@ -370,7 +370,7 @@ export function seedFixtureData(handle: Database.Database, now: number): void {
} }
} }
const metric = handle.prepare( const metric = handle.prepare(
'INSERT INTO metric_samples(at,server,game,map,players,capacity) VALUES(?,?,?,?,?,4)' 'INSERT INTO metric_samples(at,server,game,map,players,capacity) VALUES(?,?,?,?,?,NULL)'
); );
for (let minute = 0; minute < 7 * 1440; minute++) { for (let minute = 0; minute < 7 * 1440; minute++) {
for (const [index, server] of fixtureServers.entries()) { for (const [index, server] of fixtureServers.entries()) {
+79 -62
View File
@@ -1,113 +1,130 @@
import type { MapMetrics } from './metrics.js'; import type { MapMetrics } from './metrics.js';
import { metricDuration, metricPercent } from './ui/metrics.js'; import { metricDuration, metricPercent } from './ui/metrics.js';
/** Product hypotheses for advisory experiments; these are not demand forecasts. */ /** Advisory thresholds for reviewing a policy, not demand forecasts or scaling commands. */
export const HOSTING_RULES = { export const POLICY_SIGNAL_RULES = {
pressureExpansion: 0.2, noEmptyShare: 0.02,
occupancyExpansion: 0.8, minimumEvidenceHours: 1,
idleUtilization: 0.1, minimumKnownShare: 0.8
quietPressure: 0.01,
minimumEvidenceHours: 1
} as const; } as const;
export interface MetricRecommendation { export interface MetricRecommendation {
game: string; game: string;
map: string; map: string;
kind: 'expand' | 'consolidate' | 'capacity' | 'watch'; kind: 'maximum' | 'spares' | 'policy' | 'reporting' | 'watch';
title: string; title: string;
reason: string; reason: string;
evidence: string; evidence: string;
outcome: string; outcome: string;
tentative: boolean; 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[] { export function metricRecommendations(maps: MapMetrics[]): MetricRecommendation[] {
return maps return maps
.map((map) => { .map((map): MetricRecommendation => {
const utilization = map.observed > 0 ? map.active / map.observed : null; const availability = map.availabilityObserved + map.availabilityUnknown;
const pressure = map.capacityObserved > 0 ? map.full / map.capacityObserved : null; const knownShare = availability > 0 ? map.availabilityObserved / availability : 0;
const knownShare = const lobbyHours = map.observed + map.unknown;
map.observed + map.unknown > 0 ? map.observed / (map.observed + map.unknown) : 0; const knownLobbyShare = lobbyHours > 0 ? map.observed / lobbyHours : 0;
const noEmptyShare =
map.availabilityObserved > 0 ? map.noEmpty / map.availabilityObserved : null;
const policy = map.policy;
const base = { const base = {
game: map.game, game: map.game,
map: map.map, map: map.map,
evidence: evidence:
`${metricDuration(map.active)} with players / ${metricDuration(map.observed)} known server time (${metricPercent(map.active, map.observed)}). ` + `${metricDuration(map.active)} with players / ${metricDuration(map.observed)} known lobby time (${metricPercent(map.active, map.observed)}). ` +
(pressure === null (map.unknown > 0
? 'Capacity unavailable.' ? `${metricDuration(map.unknown)} of lobby time has unknown occupancy. `
: `All lobbies full ${metricDuration(map.full)} / ${metricDuration(map.capacityObserved)} known map time (${metricPercent(map.full, map.capacityObserved)}).`), : '') +
(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: tentative:
map.observed < HOSTING_RULES.minimumEvidenceHours || map.availabilityObserved < POLICY_SIGNAL_RULES.minimumEvidenceHours ||
pressure === null || knownShare < POLICY_SIGNAL_RULES.minimumKnownShare ||
map.capacityObserved < HOSTING_RULES.minimumEvidenceHours || knownLobbyShare < POLICY_SIGNAL_RULES.minimumKnownShare,
knownShare < 0.8 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 { return {
...base, ...base,
kind: 'capacity' as const, kind: 'reporting',
title: title:
map.unknown > 0 map.availabilityUnknown > 0 || map.unknown > 0
? 'Check occupancy reporting' ? 'Check occupancy reporting'
: 'Let observations accumulate', : 'Let observations accumulate',
reason: reason:
map.unknown > 0 map.availabilityUnknown > 0 || map.unknown > 0
? 'Captured status is unknown. Check a server’s telemetry or RCON diagnostics before choosing a hosting change.' ? 'Captured occupancy is unknown. Check a lobby’s telemetry or RCON diagnostics before choosing a policy change.'
: 'This map has no elapsed observations in the selected window. Wait for another status check or try a wider range.', : '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 server time in the next report.', outcome: 'Watch for known lobby availability in the next report.',
tentative: true tentative: true
}; };
if (pressure !== null && pressure >= HOSTING_RULES.pressureExpansion) if (!policy)
return { return {
...base, ...base,
kind: 'expand' as const, kind: 'policy',
title: 'Trial one additional lobby', title: 'Consider adding an autoscaling policy',
reason: 'Recorded lobbies were all full for at least 20% of known capacity time. Try one extra lobby during busy hours.', reason: 'This map has no current autoscaling policy. Set a minimum, maximum, and spare target to maintain an empty lobby as players arrive.',
outcome: 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 { return {
...base, ...base,
kind: 'expand' as const, kind: 'policy',
title: 'Trial an extra lobby during busy hours', title: policy.pausedReason
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.', ? '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: outcome:
'Check whether players use the extra lobby, and measure capacity if the server reports it.', 'Review the policy and any blockers before enabling it, then observe availability before adjusting its limits.'
tentative: true
}; };
if ( if (noEmptyShare >= POLICY_SIGNAL_RULES.noEmptyShare) {
utilization <= HOSTING_RULES.idleUtilization && if (map.noEmptyAtMaximum >= map.noEmptyBelowMaximum)
(pressure === null || pressure < HOSTING_RULES.quietPressure)
)
return { return {
...base, ...base,
kind: 'consolidate' as const, kind: 'maximum',
title: 'Trial less idle hosting', title: 'Consider raising the policy maximum',
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.', 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: outcome:
'Watch for increased all-full time or loss of player-hours, and restore the lineup if the trial hurts availability.' 'Review host resources and create time, then compare empty-lobby availability after a maximum change.'
};
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.'
}; };
return { return {
...base, ...base,
kind: 'watch' as const, kind: 'spares',
title: 'Keep the lineup and watch peaks', title: 'Review the spare target and create time',
reason: 'These observations do not trigger the expansion or idle-hosting rules. Keep the current lineup for another comparison period.', 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 whether occupied time and measured capacity pressure change.' outcome:
'Review creation delays and blockers, then compare empty-lobby availability after a spare-target change.'
};
}
return {
...base,
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) => { .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 ( return (
priority[a.kind] - priority[b.kind] || priority[a.kind] - priority[b.kind] ||
a.map.localeCompare(b.map) || a.map.localeCompare(b.map) ||
+32 -7
View File
@@ -1,4 +1,5 @@
import { DEFAULT_TIME_ZONE, validTimeZone } from './time-zone.js'; import { DEFAULT_TIME_ZONE, validTimeZone } from './time-zone.js';
import type { AutoscalingMode } from './autoscaling.js';
export type MetricsDays = 1 | 7 | 30; export type MetricsDays = 1 | 7 | 30;
@@ -58,9 +59,28 @@ export interface ServerMetrics extends Utilization {
export interface MapMetrics extends Utilization { export interface MapMetrics extends Utilization {
game: string; game: string;
map: string; map: string;
/** Map wall time, not summed server time. */ /** Map wall time, not summed lobby time. Capacity does not affect availability. */
full: number; noEmpty: number;
capacityObserved: 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 { export interface MetricPoint extends Utilization {
@@ -69,12 +89,15 @@ export interface MetricPoint extends Utilization {
startAt: number; startAt: number;
endAt: number; endAt: number;
partial: boolean; partial: boolean;
/** Captured scope wall time, excluding collection gaps. */
capturedHours: number;
} }
export interface PreviousMetrics { export interface PreviousMetrics {
since: number; since: number;
until: number; until: number;
summary: Utilization; summary: Utilization;
running: RunningMetrics;
firstSampleAt: number | null; firstSampleAt: number | null;
lastSampleAt: number | null; lastSampleAt: number | null;
/** Retained scope evidence starts late or is absent, not a completeness guarantee. */ /** Retained scope evidence starts late or is absent, not a completeness guarantee. */
@@ -89,11 +112,12 @@ export interface HeatmapCell extends Utilization {
distinctDates: number; distinctDates: number;
} }
export interface PooledPressure { export interface PooledAvailability {
/** Share of known map-hours with every tracked lobby full, from 0 to 100. */ /** Share of known map-hours with no tracked lobby empty, from 0 to 100. */
value: number | null; value: number | null;
fullMapHours: number; noEmptyMapHours: number;
knownMapHours: number; knownMapHours: number;
unknownMapHours: number;
coveredMaps: number; coveredMaps: number;
totalMaps: number; totalMaps: number;
} }
@@ -111,10 +135,11 @@ export interface MetricsData {
scopeFirstSampleAt: number | null; scopeFirstSampleAt: number | null;
scopeLatestSampleAt: number | null; scopeLatestSampleAt: number | null;
summary: Utilization; summary: Utilization;
running: RunningMetrics;
servers: ServerMetrics[]; servers: ServerMetrics[];
maps: MapMetrics[]; maps: MapMetrics[];
points: MetricPoint[]; points: MetricPoint[];
previous: PreviousMetrics; previous: PreviousMetrics;
heatmap: { timeZone: string; cells: HeatmapCell[] }; heatmap: { timeZone: string; cells: HeatmapCell[] };
pooledPressure: PooledPressure; pooledAvailability: PooledAvailability;
} }
+6 -1
View File
@@ -5,7 +5,7 @@ import { ensureAutoscalingSchema } from '../autoscaling/schema.js';
import { ensureRebalancingSchema } from '../rebalancing/schema.js'; import { ensureRebalancingSchema } from '../rebalancing/schema.js';
import { migrateFileBlobs } from './file-blobs.js'; import { migrateFileBlobs } from './file-blobs.js';
import { installPerformanceIndexes } from './performance-indexes.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 { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js';
import { migrateHostSchema } from '../hosts/schema.js'; import { migrateHostSchema } from '../hosts/schema.js';
import { migrateAuthSchema } from '../auth/schema.js'; import { migrateAuthSchema } from '../auth/schema.js';
@@ -65,6 +65,11 @@ export const migrations: readonly Migration[] = [
name: 'incremental vacuum page map', name: 'incremental vacuum page map',
mode: 'autocommit', mode: 'autocommit',
run: enableIncrementalVacuum run: enableIncrementalVacuum
},
{
version: 13,
name: 'metrics policy report revisions',
run: installMetricsPolicyRevisions
} }
]; ];
+121 -21
View File
@@ -1,6 +1,7 @@
import { fleetSettings } from '../settings.js'; import { fleetSettings } from '../settings.js';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { ONLINE_MAX_MS } from '../../server-freshness.js'; import { ONLINE_MAX_MS } from '../../server-freshness.js';
import { zombieMaps } from '../../map-aliases.js';
import { import {
parseMetricsQuery, parseMetricsQuery,
type HeatmapCell, type HeatmapCell,
@@ -10,6 +11,7 @@ import {
type MetricsDays, type MetricsDays,
type MetricsQuery, type MetricsQuery,
type PreviousMetrics, type PreviousMetrics,
type RunningMetrics,
type ServerMetrics, type ServerMetrics,
type Utilization type Utilization
} from '../../metrics.js'; } from '../../metrics.js';
@@ -41,6 +43,18 @@ function emptyUtilization(): Utilization {
return { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 }; 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 { function addObservation(total: Utilization, players: number | null, hours: number): void {
if (players === null) total.unknown += hours; if (players === null) total.unknown += hours;
else { else {
@@ -71,6 +85,72 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
const since = now - days * 86400_000; const since = now - days * 86400_000;
const bucketMs = days === 1 ? 3600_000 : 86400_000; const bucketMs = days === 1 ? 3600_000 : 86400_000;
const handle = db(); 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 // These small grouped results keep options and retained evidence independent
// of the chosen scope/window without transferring a second raw history scan. // of the chosen scope/window without transferring a second raw history scan.
const groups = handle const groups = handle
@@ -112,10 +192,12 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
maps: groups.map(({ game, map }) => ({ game, map })) maps: groups.map(({ game, map }) => ({ game, map }))
}; };
const summary = emptyUtilization(); const summary = emptyUtilization();
const running = emptyRunning();
const previous: PreviousMetrics = { const previous: PreviousMetrics = {
since: since - days * 86400_000, since: since - days * 86400_000,
until: since, until: since,
summary: emptyUtilization(), summary: emptyUtilization(),
running: emptyRunning(),
firstSampleAt: null, firstSampleAt: null,
lastSampleAt: null, lastSampleAt: null,
partialHistory: false partialHistory: false
@@ -131,6 +213,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
startAt: start, startAt: start,
endAt: end, endAt: end,
partial: start !== at || end !== at + bucketMs, partial: start !== at || end !== at + bucketMs,
capturedHours: 0,
...emptyUtilization() ...emptyUtilization()
}); });
} }
@@ -211,6 +294,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
if (priorHours > 0) { if (priorHours > 0) {
previous.firstSampleAt ??= at; previous.firstSampleAt ??= at;
previous.lastSampleAt = at; previous.lastSampleAt = at;
addRunning(previous.running, samples.length, priorHours);
for (const sample of samples) for (const sample of samples)
addObservation(previous.summary, sample.players, priorHours); 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; if (at < since - ONLINE_MAX_MS) return;
const start = Math.max(at, since); const start = Math.max(at, since);
const hours = Math.max(0, end - start) / 3600_000; 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 }; const counts: BatchCounts = { known: 0, active: 0, idle: 0, unknown: 0, players: 0 };
// Split once for the entire batch; observations crossing UTC hour/day // Split once for the entire batch; observations crossing UTC hour/day
// boundaries contribute to both buckets without shifting their duration. // 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 index = Math.floor((cursor - points[0].at) / bucketMs);
const point = points[index]; const point = points[index];
const stop = Math.min(end, point.endAt); 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; cursor = stop;
} }
for (const sample of samples) { for (const sample of samples) {
@@ -251,8 +341,12 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
map = { map = {
game: sample.game, game: sample.game,
map: sample.map, map: sample.map,
full: 0, noEmpty: 0,
capacityObserved: 0, availabilityObserved: 0,
availabilityUnknown: 0,
noEmptyAtMaximum: 0,
noEmptyBelowMaximum: 0,
policy: policyFor(sample.game, sample.map),
...emptyUtilization() ...emptyUtilization()
}; };
game.set(sample.map, map); game.set(sample.map, map);
@@ -268,21 +362,25 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
if (sample.players > 0) counts.active++; if (sample.players > 0) counts.active++;
else if (sample.players === 0) counts.idle++; else if (sample.players === 0) counts.idle++;
} }
const known = const prior = availability.get(map);
sample.players !== null && sample.capacity !== null && sample.capacity > 0; availability.set(map, {
const full = known && sample.players! >= sample.capacity!; known: sample.players !== null || (prior?.known ?? false),
const previous = pressure.get(map); empty: sample.players === 0 || (prior?.empty ?? false),
pressure.set(map, { lobbies: (prior?.lobbies ?? 0) + 1
known: known && (previous?.known ?? true),
full: full && (previous?.full ?? true)
}); });
} }
for (const [map, state] of pressure) { for (const [map, state] of availability) {
if (state.known) { if (state.known) {
map.capacityObserved += hours; map.availabilityObserved += hours;
if (state.full) map.full += 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); addHeatmap(counts, start, end);
} }
@@ -328,13 +426,14 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
previous.firstSampleAt === null; previous.firstSampleAt === null;
for (let index = 0; index < cells.length; index++) for (let index = 0; index < cells.length; index++)
cells[index].distinctDates = cellDates[index].size; cells[index].distinctDates = cellDates[index].size;
const fullMapHours = maps.reduce((total, map) => total + map.full, 0); const noEmptyMapHours = maps.reduce((total, map) => total + map.noEmpty, 0);
const knownMapHours = maps.reduce((total, map) => total + map.capacityObserved, 0); const knownMapHours = maps.reduce((total, map) => total + map.availabilityObserved, 0);
const pooledPressure = { const pooledAvailability = {
value: knownMapHours > 0 ? (100 * fullMapHours) / knownMapHours : null, value: knownMapHours > 0 ? (100 * noEmptyMapHours) / knownMapHours : null,
fullMapHours, noEmptyMapHours,
knownMapHours, 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 totalMaps: maps.length
}; };
return { return {
@@ -345,11 +444,12 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
scope, scope,
scopeOptions, scopeOptions,
summary, summary,
running,
servers, servers,
maps, maps,
points, points,
previous, previous,
heatmap: { timeZone: scope.timeZone, cells }, heatmap: { timeZone: scope.timeZone, cells },
pooledPressure pooledAvailability
}; };
} }
+16
View File
@@ -1,5 +1,21 @@
import type Database from 'better-sqlite3'; 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) { 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); 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');`); INSERT OR IGNORE INTO report_revisions(topic) VALUES ('metrics'), ('leaderboard');`);
@@ -5,6 +5,7 @@
import { createTimeFormatter } from '$lib/time-zone.js'; import { createTimeFormatter } from '$lib/time-zone.js';
import { import {
metricDuration, metricDuration,
metricAverageLobbies,
metricNumber, metricNumber,
metricPercent, metricPercent,
metricPeriodLabels metricPeriodLabels
@@ -21,9 +22,10 @@
const contextSearch = $derived( const contextSearch = $derived(
(page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search (page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search
); );
const mode = $derived( const mode = $derived.by(() => {
new URLSearchParams(contextSearch).get('chartMode') === 'hours' ? 'hours' : 'percent' const value = new URLSearchParams(contextSearch).get('chartMode');
); return value === 'hours' || value === 'percent' ? value : 'average';
});
const selectedIndex = $derived.by(() => { const selectedIndex = $derived.by(() => {
const existing = points.findIndex((point) => point.at === selectedAt); const existing = points.findIndex((point) => point.at === selectedAt);
if (existing >= 0) return existing; if (existing >= 0) return existing;
@@ -34,7 +36,15 @@
const selectedValue = $derived(selected ? String(selected.at) : ''); const selectedValue = $derived(selected ? String(selected.at) : '');
const ceiling = $derived.by(() => { const ceiling = $derived.by(() => {
if (mode === 'percent') return 100; 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; if (!max) return 1;
const magnitude = 10 ** Math.floor(Math.log10(max)); const magnitude = 10 ** Math.floor(Math.log10(max));
return Math.ceil(max / (magnitude / 2)) * (magnitude / 2); return Math.ceil(max / (magnitude / 2)) * (magnitude / 2);
@@ -55,10 +65,10 @@
const periodLabels = $derived(metricPeriodLabels(points, days, timeZone)); const periodLabels = $derived(metricPeriodLabels(points, days, timeZone));
const captured = $derived(selected ? selected.observed + selected.unknown : 0); 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); const url = new URL(page.url);
url.search = contextSearch; url.search = contextSearch;
if (value === 'percent') url.searchParams.delete('chartMode'); if (value === 'average') url.searchParams.delete('chartMode');
else url.searchParams.set('chartMode', value); else url.searchParams.set('chartMode', value);
replaceState(url, { ...page.state, metricsTableSearch: url.search }); replaceState(url, { ...page.state, metricsTableSearch: url.search });
} }
@@ -66,12 +76,27 @@
selectedAt = at; selectedAt = at;
const point = points.find((item) => item.at === at); const point = points.find((item) => item.at === at);
if (!point) return; 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) { 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; 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) { function move(direction: number) {
const next = points[Math.max(0, Math.min(points.length - 1, selectedIndex + direction))]; const next = points[Math.max(0, Math.min(points.length - 1, selectedIndex + direction))];
if (next) selectPeriod(next.at); if (next) selectPeriod(next.at);
@@ -98,15 +123,22 @@
<section class="panel" aria-labelledby="metrics-trend-title"> <section class="panel" aria-labelledby="metrics-trend-title">
<div class="heading"> <div class="heading">
<div> <div>
<h2 id="metrics-trend-title">Server activity over time</h2> <h2 id="metrics-trend-title">Lobbies running over time</h2>
<p> <p>
{mode === 'percent' {mode === 'average'
? 'Share of captured server time, including unknown status.' ? 'Average concurrent lobbies during captured time, including unknown status.'
: `Server-hours per ${days === 1 ? 'hour' : '24-hour period'}.`} Partial periods contain : mode === 'percent'
less elapsed time. ? 'Share of captured lobby time, including unknown status.'
: `Lobby-hours per ${days === 1 ? 'hour' : '24-hour period'}.`}
Partial periods contain less elapsed time.
</p> </p>
</div> </div>
<div class="mode" role="group" aria-label="Timeline measure"> <div class="mode" role="group" aria-label="Timeline measure">
<button
type="button"
aria-pressed={mode === 'average'}
onclick={() => setMode('average')}>Average lobbies</button
>
<button <button
type="button" type="button"
aria-pressed={mode === 'percent'} aria-pressed={mode === 'percent'}
@@ -117,12 +149,16 @@
</div> </div>
</div> </div>
<div class="legend"> <div class="legend">
<span><i class="occupied"></i>With players</span><span><i class="idle"></i>Idle</span><span <span><i class="occupied"></i>With players</span><span><i class="idle"></i>Empty spare</span
><i class="unknown"></i>Unknown</span ><span><i class="unknown"></i>Unknown</span><span class="partial-key">* Partial period</span
><span class="partial-key">* Partial period</span> >
</div> </div>
<div class="axis-title"> <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>
<div class="chart-grid"> <div class="chart-grid">
<div class="y-axis" aria-hidden="true"> <div class="y-axis" aria-hidden="true">
@@ -133,7 +169,7 @@
<div <div
class="plot" class="plot"
role="group" 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"> <div class="grid-lines" aria-hidden="true">
{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`} {#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}
@@ -147,7 +183,7 @@
type="button" type="button"
tabindex={index === selectedIndex ? 0 : -1} tabindex={index === selectedIndex ? 0 : -1}
aria-pressed={point.at === selected?.at} 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' : ''}`} title={`${format.period(point.startAt)} – ${format.period(point.endAt)}${point.partial ? ' · partial period' : ''}`}
onclick={() => selectPeriod(point.at)} onclick={() => selectPeriod(point.at)}
onkeydown={(event) => chartKey(event, index)} onkeydown={(event) => chartKey(event, index)}
@@ -192,8 +228,8 @@
: ''}</time : ''}</time
><span ><span
>{captured >{captured
? `${metricDuration(selected.active)} with players · ${metricDuration(selected.idle)} idle · ${metricDuration(selected.unknown)} unknown · ${selected.observed ? metricNumber(selected.playerHours) : '—'} player-hours` ? `${pointSummary(selected)} · ${selected.observed ? metricNumber(selected.playerHours) : '—'} player-hours`
: 'No captured time · not evidence of idle servers'}</span : 'No captured time · not evidence of empty lobbies'}</span
> >
</div> </div>
<details class="inspection"> <details class="inspection">
@@ -228,7 +264,7 @@
</p> </p>
{#if captured === 0}<p class="empty"> {#if captured === 0}<p class="empty">
No captured time in this period. Missing observations do not mean the fleet No captured time in this period. Missing observations do not mean the fleet
was idle. had empty lobbies.
</p> </p>
{:else}<dl> {:else}<dl>
<div> <div>
@@ -243,7 +279,7 @@
</dd> </dd>
</div> </div>
<div> <div>
<dt>Idle</dt> <dt>Empty spare</dt>
<dd> <dd>
{metricDuration(selected.idle)}<small {metricDuration(selected.idle)}<small
>{metricPercent(selected.idle, captured)} of captured time</small >{metricPercent(selected.idle, captured)} of captured time</small
@@ -264,9 +300,10 @@
</div> </div>
</dl> </dl>
<p class="denominator"> <p class="denominator">
{metricDuration(captured)} captured server time · {metricDuration( {metricDuration(captured)} captured lobby time · {metricDuration(
selected.observed 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} </p>{/if}
</div> </div>
</details> </details>
@@ -283,9 +320,11 @@
> >
<thead <thead
><tr ><tr
><th scope="col">Period</th><th scope="col">With players</th><th scope="col" ><th scope="col">Period</th><th scope="col">Average lobbies</th><th
>Idle</th scope="col">With players</th
><th scope="col">Unknown</th><th scope="col">Player-hours</th></tr ><th scope="col">Empty spare</th><th scope="col">Unknown</th><th scope="col"
>Player-hours</th
></tr
></thead ></thead
> >
<tbody <tbody
@@ -296,11 +335,11 @@
? ' · Partial' ? ' · Partial'
: ''}</small : ''}</small
></th ></th
><td>{metricDuration(point.active)}</td><td ><td>{averageLabel(point, point.observed + point.unknown)}</td><td
>{metricDuration(point.idle)}</td >{metricDuration(point.active)}</td
><td>{metricDuration(point.unknown)}</td><td ><td>{metricDuration(point.idle)}</td><td
>{point.observed ? metricNumber(point.playerHours) : '—'}</td >{metricDuration(point.unknown)}</td
></tr ><td>{point.observed ? metricNumber(point.playerHours) : '—'}</td></tr
>{/each}</tbody >{/each}</tbody
> >
</table> </table>
@@ -5,14 +5,12 @@
import Select from '$ui/components/primitives/Select.svelte'; import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte'; import Field from '$ui/components/primitives/Field.svelte';
import type { MetricsData } from '$lib/metrics.js'; 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(); let { report }: { report: MetricsData } = $props();
const recommendations = $derived(metricRecommendations(report.maps)); const recommendations = $derived(metricRecommendations(report.maps));
const highlighted = $derived( const highlighted = $derived(
recommendations recommendations
.filter( .filter((item) => !item.tentative && item.kind !== 'watch' && item.kind !== 'reporting')
(item) => !item.tentative && (item.kind === 'expand' || item.kind === 'consolidate')
)
.slice(0, 3) .slice(0, 3)
); );
const remaining = $derived(recommendations.filter((item) => !highlighted.includes(item))); const remaining = $derived(recommendations.filter((item) => !highlighted.includes(item)));
@@ -49,6 +47,7 @@
? ' · Limited evidence' ? ' · Limited evidence'
: ''}</small : ''}</small
> >
{#if suggestion.policySummary}<small>{suggestion.policySummary}</small>{/if}
</div> </div>
<div> <div>
<strong>{suggestion.title}</strong> <strong>{suggestion.title}</strong>
@@ -58,17 +57,24 @@
<p>{suggestion.reason}</p> <p>{suggestion.reason}</p>
<p>{suggestion.outcome}</p> <p>{suggestion.outcome}</p>
</details> </details>
{#if suggestion.href && suggestion.action}
<a class="policy-link" href={suggestion.href}>{suggestion.action}</a>
{/if}
</div> </div>
</article> </article>
{/snippet} {/snippet}
<section class="explore" aria-labelledby="metrics-explore-title"> <section class="explore" aria-labelledby="metrics-explore-title">
<h2 id="metrics-explore-title">Hosting insights</h2> <h2 id="metrics-explore-title">Autoscaling signals</h2>
<p>Historical signals for hosting trials. Check the current lineup before making a change.</p> <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} {#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"> {#if limited}<p class="notice">
{limited} of {recommendations.length} maps have limited evidence. Review occupancy reporting, {limited} of {recommendations.length} maps have limited evidence. Review occupancy reporting
player limits, and observation duration before changing hosting. and observation duration before changing a policy.
</p>{/if} </p>{/if}
{#if remaining.length}<details class="remaining"> {#if remaining.length}<details class="remaining">
<summary>Other findings ({remaining.length})</summary> <summary>Other findings ({remaining.length})</summary>
@@ -104,31 +110,33 @@
</p>{/each} </p>{/each}
</details>{/if} </details>{/if}
<details class="rules"> <details class="rules">
<summary>How Rules v1 chooses a suggestion</summary> <summary>How policy signals are chosen</summary>
<ul> <ul>
<li> <li>
Trial an additional lobby when all tracked lobbies are full for at least {HOSTING_RULES.pressureExpansion * Review a policy when no empty lobby was reported for at least {POLICY_SIGNAL_RULES.noEmptyShare *
100}% of known-capacity time. 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>
<li> <li>
When capacity is unavailable, at least {HOSTING_RULES.occupancyExpansion * 100}% An empty lobby has a reported player count of zero. When some lobbies report players
occupied known time can suggest a tentative additional-lobby trial. Occupancy does and the rest are unknown, none reports empty; unknown lobbies may still have been
not establish saturation. empty. Player limits are not needed for these signals.
</li> </li>
<li> <li>
Review idle hosting when occupied known time is at most {HOSTING_RULES.idleUtilization * Maps without a current policy suggest adding one. Paused or disabled policies prompt
100}%, and either measured pressure is below {HOSTING_RULES.quietPressure * a review before resuming or enabling them.
100}% or capacity is unavailable.
</li> </li>
<li> <li>
Missing occupancy prompts a reporting check. Other cases suggest watching hosting or All-unknown occupancy prompts a reporting check. Other cases suggest watching
checking capacity reporting. empty-lobby availability.
</li> </li>
</ul> </ul>
<p> <p>
Less than {HOSTING_RULES.minimumEvidenceHours} hour of relevant known time, missing capacity, Less than {POLICY_SIGNAL_RULES.minimumEvidenceHours} hour of known map availability, or less
or substantial unknown status marks advice as tentative. These are trial policies, not validated than {POLICY_SIGNAL_RULES.minimumKnownShare * 100}% known map availability or lobby
demand forecasts. Collection gaps are omitted from all rules. occupancy, marks a signal as tentative. These signals are advisory, not demand
forecasts. Collection gaps are omitted from all rules.
</p> </p>
</details> </details>
</section> </section>
@@ -185,6 +193,11 @@
border-left: 2px solid var(--state-warning); border-left: 2px solid var(--state-warning);
padding-left: 12px; padding-left: 12px;
} }
.policy-link {
display: inline-block;
margin-top: 10px;
color: var(--accent-primary);
}
li { li {
margin: 8px 0; margin: 8px 0;
color: var(--text-secondary); color: var(--text-secondary);
@@ -1,19 +1,14 @@
<script lang="ts"> <script lang="ts">
import type { MetricsData } from '$lib/metrics.js'; import type { MetricsData } from '$lib/metrics.js';
import { metricDuration, metricNumber, metricPercent } from '$ui/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 Card from '$ui/components/primitives/Card.svelte';
import Stat from '$ui/components/primitives/Stat.svelte'; import Stat from '$ui/components/primitives/Stat.svelte';
let { report }: { report: MetricsData } = $props(); let { report }: { report: MetricsData } = $props();
const prior = $derived(report.previous); 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 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( const playerDelta = $derived(
comparable && prior.summary.playerHours > 0 comparable && prior.summary.playerHours > 0
? 100 * (report.summary.playerHours / prior.summary.playerHours - 1) ? 100 * (report.summary.playerHours / prior.summary.playerHours - 1)
@@ -32,19 +27,21 @@
<div class="summary"> <div class="summary">
<Card <Card
><Stat ><Stat
label="Time with players" label="Lobbies running"
value={metricPercent(report.summary.active, report.summary.observed)} value={report.running.average === null
? '—'
: `${metricNumber(report.running.average)} avg`}
size="lg" size="lg"
tween={false} tween={false}
/> />
<p> <p>
{occupancyDelta === null {report.running.average === null
? 'Comparison unavailable' ? 'Peak unavailable'
: `${signed(occupancyDelta, ' percentage points')} vs ${previousLabel}`} : `Peak ${countLabel(report.running.peak, 'lobby', 'lobbies')} at once`}
</p> </p>
<small <small
>{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} observed >{metricDuration(report.summary.idle)} of {metricDuration(captured)} captured lobby-hours
server time</small were empty spares ({metricPercent(report.summary.idle, captured)})</small
> >
</Card> </Card>
<Card <Card
@@ -68,15 +65,15 @@
</Card> </Card>
<Card <Card
><Stat ><Stat
label="Time all lobbies were full" label="Time with no empty lobby"
value={metricPercent(pressure.fullMapHours, pressure.knownMapHours)} value={metricPercent(availability.noEmptyMapHours, availability.knownMapHours)}
size="lg" size="lg"
tween={false} tween={false}
/> />
<p> <p>
{pressure.knownMapHours > 0 {availability.knownMapHours > 0
? `${pressure.coveredMaps} of ${pressure.totalMaps} maps have capacity data` ? `${availability.coveredMaps} of ${countLabel(availability.totalMaps, 'map')} ${plural(availability.totalMaps, 'has', 'have')} availability data`
: 'Capacity data unavailable'} : 'Lobby availability unavailable'}
</p> </p>
<small>Combined map history · each map measured separately</small> <small>Combined map history · each map measured separately</small>
</Card> </Card>
@@ -84,13 +81,9 @@
{#if prior.partialHistory}<p class="notice"> {#if prior.partialHistory}<p class="notice">
Partial previous history. Comparisons use available observations without extrapolation. Partial previous history. Comparisons use available observations without extrapolation.
</p>{/if} </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"> {#if report.summary.unknown > 0}<p class="quality">
{metricDuration(report.summary.unknown)} of captured status is unknown and excluded from “Time {metricDuration(report.summary.unknown)} of captured lobby time had unknown status. Unknown lobbies
with players.” Collection gaps are separate. are included in running counts and excluded from empty spares. Collection gaps are separate.
</p>{/if} </p>{/if}
<style> <style>
@@ -25,7 +25,7 @@
{ key: 'map', label: 'Map' }, { key: 'map', label: 'Map' },
{ key: 'playerHours', label: 'Player-hours' }, { key: 'playerHours', label: 'Player-hours' },
{ key: 'active', label: 'With players' }, { key: 'active', label: 'With players' },
{ key: 'full', label: 'All lobbies full' }, { key: 'noEmpty', label: 'No empty lobby' },
{ key: 'details', label: 'Details' } { key: 'details', label: 'Details' }
] as const; ] as const;
const serverColumns = [ const serverColumns = [
@@ -109,7 +109,8 @@
} }
function mapValue(map: MapMetrics, key: Exclude<MapSort, 'map'>): number | null { function mapValue(map: MapMetrics, key: Exclude<MapSort, 'map'>): number | null {
if (key === 'details') return null; if (key === 'details') return null;
if (key === '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]; return (key === 'playerHours' || key === 'active') && !map.observed ? null : map[key];
} }
function serverValue(server: ServerMetrics, key: Exclude<ServerSort, 'server'>): number | null { 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'}> <section class="panel" aria-label={view === 'maps' ? 'Map metrics' : 'Instance metrics'}>
<div class="heading"> <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"> <div class="search">
<Field label={view === 'maps' ? 'Find a map' : 'Find an instance'} <Field label={view === 'maps' ? 'Find a map' : 'Find an instance'}
><Input ><Input
@@ -225,8 +226,9 @@
<th scope="row" <th scope="row"
>{map.map}<small >{map.map}<small
>{gameName(map.game)}{map.observed < 1 || >{gameName(map.game)}{map.observed < 1 ||
map.capacityObserved < 1 || map.availabilityObserved < 1 ||
map.unknown > map.observed / 4 map.unknown > map.observed / 4 ||
map.availabilityUnknown > map.availabilityObserved / 4
? ' · Limited evidence' ? ' · Limited evidence'
: ''}</small : ''}</small
></th ></th
@@ -240,23 +242,25 @@
</div></td </div></td
> >
<td>{map.observed ? metricDuration(map.active) : '—'}</td> <td>{map.observed ? metricDuration(map.active) : '—'}</td>
<td>{metricPercent(map.full, map.capacityObserved)}</td> <td>{metricPercent(map.noEmpty, map.availabilityObserved)}</td>
<td <td
><details> ><details>
<summary <summary
><span class="sr-only">{map.map} </span>Details</summary ><span class="sr-only">{map.map} </span>Details</summary
> >
<dl> <dl>
<dt>Observed instance time</dt> <dt>Observed lobby time</dt>
<dd>{metricDuration(map.observed)}</dd> <dd>{metricDuration(map.observed)}</dd>
<dt>Unknown status</dt> <dt>Unknown status</dt>
<dd>{metricDuration(map.unknown)}</dd> <dd>{metricDuration(map.unknown)}</dd>
<dt>All lobbies full</dt> <dt>No empty lobby</dt>
<dd> <dd>
{map.capacityObserved {map.availabilityObserved
? `${metricDuration(map.full)} of ${metricDuration(map.capacityObserved)} known map time` ? `${metricDuration(map.noEmpty)} of ${metricDuration(map.availabilityObserved)} known map time`
: 'Capacity data unavailable'} : 'Lobby availability unavailable'}
</dd> </dd>
<dt>Unknown map availability</dt>
<dd>{metricDuration(map.availabilityUnknown)}</dd>
</dl> </dl>
</details></td </details></td
> >
@@ -289,7 +293,7 @@
<dd><code>{server.server}</code></dd> <dd><code>{server.server}</code></dd>
<dt>Time with players</dt> <dt>Time with players</dt>
<dd>{metricDuration(server.active)}</dd> <dd>{metricDuration(server.active)}</dd>
<dt>Idle</dt> <dt>Empty spare</dt>
<dd> <dd>
{metricPercent(server.idle, server.observed)} · {metricDuration( {metricPercent(server.idle, server.observed)} · {metricDuration(
server.idle server.idle
@@ -338,7 +342,7 @@
<summary>About these metrics</summary> <summary>About these metrics</summary>
<p> <p>
{view === 'maps' {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.'} : '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> </p>
</details> </details>
+29 -7
View File
@@ -5,12 +5,25 @@ const record = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value); typeof value === 'object' && value !== null && !Array.isArray(value);
const numeric = (value: unknown): value is number => const numeric = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value); 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 timestamp = (value: unknown) => value === null || numeric(value);
const totals = (value: unknown) => const totals = (value: unknown) =>
record(value) && record(value) &&
['observed', 'active', 'idle', 'unknown', 'playerHours'].every( ['observed', 'active', 'idle', 'unknown', 'playerHours'].every(
(key) => numeric(value[key]) && value[key] >= 0 (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 { function validReport(value: unknown): value is MetricsData {
if ( if (
@@ -18,7 +31,8 @@ function validReport(value: unknown): value is MetricsData {
![1, 7, 30].includes(value.days as number) || ![1, 7, 30].includes(value.days as number) ||
!numeric(value.now) || !numeric(value.now) ||
!numeric(value.since) || !numeric(value.since) ||
!totals(value.summary) !totals(value.summary) ||
!running(value.running)
) )
return false; return false;
if ( if (
@@ -58,6 +72,7 @@ function validReport(value: unknown): value is MetricsData {
numeric(item.at) && numeric(item.at) &&
numeric(item.startAt) && numeric(item.startAt) &&
numeric(item.endAt) && numeric(item.endAt) &&
nonnegative(item.capturedHours) &&
typeof item.partial === 'boolean' typeof item.partial === 'boolean'
) )
) )
@@ -70,8 +85,14 @@ function validReport(value: unknown): value is MetricsData {
totals(item) && totals(item) &&
typeof item.game === 'string' && typeof item.game === 'string' &&
typeof item.map === '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; return false;
@@ -89,6 +110,7 @@ function validReport(value: unknown): value is MetricsData {
if ( if (
!record(value.previous) || !record(value.previous) ||
!totals(value.previous.summary) || !totals(value.previous.summary) ||
!running(value.previous.running) ||
!numeric(value.previous.since) || !numeric(value.previous.since) ||
!numeric(value.previous.until) || !numeric(value.previous.until) ||
!timestamp(value.previous.firstSampleAt) || !timestamp(value.previous.firstSampleAt) ||
@@ -111,10 +133,10 @@ function validReport(value: unknown): value is MetricsData {
) )
return false; return false;
return ( return (
record(value.pooledPressure) && record(value.pooledAvailability) &&
(value.pooledPressure.value === null || numeric(value.pooledPressure.value)) && (value.pooledAvailability.value === null || nonnegative(value.pooledAvailability.value)) &&
['fullMapHours', 'knownMapHours', 'coveredMaps', 'totalMaps'].every((key) => ['noEmptyMapHours', 'knownMapHours', 'unknownMapHours', 'coveredMaps', 'totalMaps'].every(
numeric((value.pooledPressure as Record<string, unknown>)[key]) (key) => nonnegative((value.pooledAvailability as Record<string, unknown>)[key])
) )
); );
} }
+7
View File
@@ -31,6 +31,13 @@ export function metricNumber(value: number): string {
return numberFormat.format(value); 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 { export function metricPercent(value: number, total: number): string {
if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return '—'; if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return '—';
const percent = (100 * value) / total; const percent = (100 * value) / total;
+28 -13
View File
@@ -6,7 +6,7 @@
import { timeZone } from '$ui/stores/time-zone.js'; import { timeZone } from '$ui/stores/time-zone.js';
import { createTimeFormatter } from '$lib/time-zone.js'; import { createTimeFormatter } from '$lib/time-zone.js';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { metricDuration } from '$ui/metrics.js'; import { metricDuration, metricNumber } from '$ui/metrics.js';
import { createMetricsRefresh } from '$ui/metrics-refresh.js'; import { createMetricsRefresh } from '$ui/metrics-refresh.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Card from '$ui/components/primitives/Card.svelte'; import Card from '$ui/components/primitives/Card.svelte';
@@ -301,8 +301,8 @@
</p> </p>
{/if} {/if}
{#if report.summary.observed === 0}<p class="notice"> {#if report.summary.observed === 0}<p class="notice">
All captured server status is unknown. A dash means occupancy could not be All captured lobby status is unknown. Running counts include these lobbies, but
calculated; it does not mean servers were empty. their occupancy and availability could not be determined.
</p>{/if} </p>{/if}
<MetricsChart points={report.points} days={report.days} timeZone={report.scope.timeZone} /> <MetricsChart points={report.points} days={report.days} timeZone={report.scope.timeZone} />
{/if} {/if}
@@ -316,7 +316,7 @@
tabs={[ tabs={[
{ id: 'maps', label: 'Maps' }, { id: 'maps', label: 'Maps' },
{ id: 'instances', label: 'Instances' }, { id: 'instances', label: 'Instances' },
{ id: 'insights', label: 'Hosting insights' } { id: 'insights', label: 'Autoscaling signals' }
]} ]}
value={view} value={view}
onchange={changeView} onchange={changeView}
@@ -364,15 +364,28 @@
<p> <p>
Previous window: {format.period(report.previous.since)} – {format.period( Previous window: {format.period(report.previous.since)} – {format.period(
report.previous.until report.previous.until
)}. Known time: {metricDuration(report.previous.summary.observed)} previous / {metricDuration( )}. Average lobbies running: {report.previous.running.average === null
report.summary.observed ? '—'
)} current. Percentage points show the difference between two percentages. Equal windows do : metricNumber(report.previous.running.average)} previous /
not guarantee equal observation coverage. {report.running.average === null ? '—' : metricNumber(report.running.average)} current. Captured
wall time: {metricDuration(report.previous.running.capturedHours)} previous /
{metricDuration(report.running.capturedHours)} current. Equal windows do not guarantee equal
observation coverage.
</p> </p>
<p> <p>
Time with players is occupied server time divided by known server time. Known time Lobbies running averages the concurrent lobby count over captured wall time and includes
includes occupied and idle observations; unknown status and collection gaps are excluded lobbies with unknown status. Peak is the largest captured concurrent count. Empty spare
from that percentage. share divides lobby time with zero reported players by all captured lobby-hours,
including unknown status. Empty spares are expected when an autoscaling policy keeps
lobbies ready.
</p>
<p>
No empty lobby measures map time when at least one lobby has known occupancy and no
tracked lobby reports zero players. If every lobby is unknown, map availability is
unknown and excluded from the percentage. Player limits are not required. This
availability signal does not establish failed joins or unmet demand. Autoscaling signals
compare observations with the current policy; historical policy settings are not
recorded.
</p> </p>
<p> <p>
Each periodic observation covers at most {ONLINE_MAX_MS / 1000} seconds. Longer collection Each periodic observation covers at most {ONLINE_MAX_MS / 1000} seconds. Longer collection
@@ -384,8 +397,10 @@
'_', '_',
' ' ' '
)}. Daily buckets are 24-hour periods, not local calendar days. The first and latest )}. Daily buckets are 24-hour periods, not local calendar days. The first and latest
buckets can be partial. Percentage bars divide occupied, idle, and unknown time by buckets can be partial. Average bars divide occupied, empty spare, and unknown
captured time; the headline occupancy percentage uses known time only. lobby-hours by captured wall time within each bucket, so a partial period is not a lower
lobby count merely because it is shorter. Percentage bars use all captured lobby time.
Instance time with players uses known lobby time only.
</p> </p>
<p> <p>
Historical server rows are grouped by name and may include multiple registrations using Historical server rows are grouped by name and may include multiple registrations using
+29
View File
@@ -138,3 +138,32 @@ it('rejects a database from a newer application without changing its version or
inspect.close(); inspect.close();
} }
}); });
it('adds policy report invalidation when upgrading a database at version 12', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const handle = db();
for (const table of ['autoscaling_policies', 'settings'])
for (const action of ['INSERT', 'UPDATE', 'DELETE'])
handle.exec(`DROP TRIGGER report_metrics_${table}_${action}`);
handle.pragma('user_version = 12');
closeDb();
const upgraded = db();
const revision = () =>
(
upgraded
.prepare("SELECT revision FROM report_revisions WHERE topic='metrics'")
.get() as { revision: number }
).revision;
const before = revision();
upgraded
.prepare(
"INSERT INTO settings(key,value) VALUES('autoscaling_paused','1') ON CONFLICT(key) DO UPDATE SET value='1'"
)
.run();
expect(revision()).toBeGreaterThan(before);
const paused = revision();
upgraded.prepare("INSERT INTO settings(key,value) VALUES('unrelated-setting','1')").run();
expect(revision()).toBe(paused);
upgraded.prepare("DELETE FROM settings WHERE key='autoscaling_paused'").run();
expect(revision()).toBeGreaterThan(paused);
});
+145 -37
View File
@@ -2,6 +2,14 @@ import { describe, expect, it } from 'vitest';
import { metricRecommendations } from '../../src/lib/metrics-insights.js'; import { metricRecommendations } from '../../src/lib/metrics-insights.js';
import type { MapMetrics } from '../../src/lib/metrics.js'; import type { MapMetrics } from '../../src/lib/metrics.js';
const policy = {
id: 't5-policy',
mode: 'create-destroy' as const,
minimum: 1,
maximum: 3,
spares: 1,
pausedReason: null
};
const map = (values: Partial<MapMetrics> = {}): MapMetrics => ({ const map = (values: Partial<MapMetrics> = {}): MapMetrics => ({
game: 't5', game: 't5',
map: 'Moon', map: 'Moon',
@@ -10,44 +18,148 @@ const map = (values: Partial<MapMetrics> = {}): MapMetrics => ({
idle: 5, idle: 5,
unknown: 0, unknown: 0,
playerHours: 10, playerHours: 10,
full: 0, noEmpty: 0,
capacityObserved: 5, availabilityObserved: 5,
availabilityUnknown: 0,
noEmptyAtMaximum: 0,
noEmptyBelowMaximum: 0,
policy,
...values ...values
}); });
describe('advisory hosting experiments', () => { describe('autoscaling policy signals', () => {
it('uses map capacity time rather than summed server time for expansion', () => { it('compares map availability with the current maximum without needing player limits', () => {
const [result] = metricRecommendations([ const [result] = metricRecommendations([
map({ observed: 100, active: 50, idle: 50, full: 1, capacityObserved: 4 }) map({
observed: 100,
active: 50,
idle: 50,
noEmpty: 0.1,
availabilityObserved: 4,
noEmptyAtMaximum: 0.1
})
]); ]);
expect(result.kind).toBe('expand'); expect(result).toMatchObject({
expect(result.evidence).toContain('25.0%'); kind: 'maximum',
expect(result.tentative).toBe(false); tentative: false,
href: '/autoscaling#t5-policy',
action: 'Review policy'
});
expect(result.evidence).toContain('2.5%');
expect(result.evidence).toContain('At or above the current maximum (3)');
expect(result.policySummary).toBe('Current policy: 1–3 lobbies, 1 spare');
}); });
it('offers a tentative trial even with a tiny recorded sample', () => { it('reviews the spare target and create time for no-empty time below the maximum', () => {
const [result] = metricRecommendations([
map({ noEmpty: 1, noEmptyBelowMaximum: 0.9, noEmptyAtMaximum: 0.1 })
]);
expect(result.kind).toBe('spares');
expect(result.reason).toContain('below the current maximum of 3');
expect(result.reason).toContain('raising the spare target from 1');
expect(result.title).toContain('create time');
expect(result.evidence).toContain('below it: 0.9 h');
});
it('uses the larger no-empty duration and favors maximum review on a tie', () => {
const [result] = metricRecommendations([
map({ noEmpty: 1, noEmptyBelowMaximum: 0.5, noEmptyAtMaximum: 0.5 })
]);
expect(result.kind).toBe('maximum');
expect(result.reason).toContain('At least half');
});
it('uses the two-percent threshold without treating occupied time as demand', () => {
expect(
metricRecommendations([
map({ active: 9.99, idle: 0.01, noEmpty: 0.099, noEmptyAtMaximum: 0.099 })
])[0].kind
).toBe('watch');
expect(metricRecommendations([map({ noEmpty: 0.1, noEmptyAtMaximum: 0.1 })])[0].kind).toBe(
'maximum'
);
expect(metricRecommendations([map({ active: 0.1, idle: 9.9 })])[0].kind).toBe('watch');
});
it('marks a tiny recorded sample as tentative', () => {
const [result] = metricRecommendations([ const [result] = metricRecommendations([
map({ map({
observed: 1 / 120, observed: 1 / 120,
active: 1 / 120, active: 1 / 120,
idle: 0, idle: 0,
full: 1 / 120, noEmpty: 1 / 120,
capacityObserved: 1 / 120 availabilityObserved: 1 / 120,
noEmptyAtMaximum: 1 / 120
}) })
]); ]);
expect(result.kind).toBe('expand'); expect(result.kind).toBe('maximum');
expect(result.tentative).toBe(true); expect(result.tentative).toBe(true);
expect(result.evidence).toContain('30 s'); expect(result.evidence).toContain('30 s');
}); });
it('uses explicitly tentative occupancy advice when capacity is missing', () => { it('measures evidence coverage with map availability time', () => {
const [result] = metricRecommendations([map({ active: 8, idle: 2, capacityObserved: 0 })]); const [result] = metricRecommendations([
expect(result.kind).toBe('expand'); map({
noEmpty: 1,
noEmptyBelowMaximum: 1,
availabilityObserved: 5,
availabilityUnknown: 2,
observed: 100
})
]);
expect(result.kind).toBe('spares');
expect(result.tentative).toBe(true); expect(result.tentative).toBe(true);
expect(result.reason).toContain('not evidence of saturation'); expect(
metricRecommendations([map({ availabilityObserved: 4, availabilityUnknown: 1 })])[0]
.tentative
).toBe(false);
}); });
it('does not confuse unknown occupancy with an idle fleet', () => { it('suggests adding a policy for a map without one', () => {
const [result] = metricRecommendations([map({ policy: null })]);
expect(result).toMatchObject({
kind: 'policy',
title: 'Consider adding an autoscaling policy',
href: '/autoscaling',
action: 'Add policy',
tentative: false
});
});
it('keeps mixed no-empty evidence tentative when most lobby occupancy is unknown', () => {
const [result] = metricRecommendations([
map({
observed: 2,
active: 2,
idle: 0,
unknown: 18,
availabilityObserved: 2,
availabilityUnknown: 0,
noEmpty: 2,
noEmptyAtMaximum: 2
})
]);
expect(result.kind).toBe('maximum');
expect(result.tentative).toBe(true);
expect(result.evidence).toContain('18 h of lobby time has unknown occupancy');
});
it('reviews paused or disabled policies before suggesting limit changes', () => {
const values = { noEmpty: 1, noEmptyAtMaximum: 1 };
const [paused] = metricRecommendations([
map({ ...values, policy: { ...policy, pausedReason: 'Review a failed host command.' } })
]);
expect(paused.kind).toBe('policy');
expect(paused.title).toBe('Review and resume the autoscaling policy');
expect(paused.reason).toContain('Review a failed host command.');
const [disabled] = metricRecommendations([
map({ ...values, policy: { ...policy, mode: 'off' } })
]);
expect(disabled.kind).toBe('policy');
expect(disabled.title).toBe('Review and enable the autoscaling policy');
});
it('does not confuse all-unknown occupancy with an idle fleet or a policy signal', () => {
const [result] = metricRecommendations([ const [result] = metricRecommendations([
map({ map({
observed: 0, observed: 0,
@@ -55,37 +167,33 @@ describe('advisory hosting experiments', () => {
idle: 0, idle: 0,
unknown: 10, unknown: 10,
playerHours: 0, playerHours: 0,
capacityObserved: 0 availabilityObserved: 0,
availabilityUnknown: 5,
policy: null
}) })
]); ]);
expect(result.kind).toBe('capacity'); expect(result.kind).toBe('reporting');
expect(result.title).toBe('Check occupancy reporting'); expect(result.title).toBe('Check occupancy reporting');
expect(result.tentative).toBe(true); expect(result.tentative).toBe(true);
}); });
it('keeps advice scoped to input maps and considers idle trials with incomplete capacity', () => {
const [result] = metricRecommendations([
map({ game: 't6', map: 'Town', active: 1, idle: 9, capacityObserved: 0 })
]);
expect(result).toMatchObject({
game: 't6',
map: 'Town',
kind: 'consolidate',
tentative: true
});
expect(result.reason).toContain('preserve access');
expect(metricRecommendations([])).toEqual([]);
});
it('distinguishes a zero-duration sample from failed occupancy reporting', () => { it('distinguishes a zero-duration sample from failed occupancy reporting', () => {
const [result] = metricRecommendations([ const [result] = metricRecommendations([
map({ observed: 0, active: 0, idle: 0, playerHours: 0, capacityObserved: 0 }) map({ observed: 0, active: 0, idle: 0, playerHours: 0, availabilityObserved: 0 })
]); ]);
expect(result.title).toBe('Let observations accumulate'); expect(result.title).toBe('Let observations accumulate');
expect(result.reason).toContain('no elapsed observations'); expect(result.reason).toContain('no elapsed availability observations');
}); });
it('leaves the lineup alone when measured evidence does not trigger a rule', () => { it('keeps advice scoped to input maps and sorts policy actions before quiet findings', () => {
expect(metricRecommendations([map({ active: 9, idle: 1, full: 0 })])[0].kind).toBe('watch'); const results = metricRecommendations([
map({ map: 'Moon' }),
map({ game: 't6', map: 'Town', noEmpty: 1, noEmptyBelowMaximum: 1 })
]);
expect(results.map(({ game, map, kind }) => ({ game, map, kind }))).toEqual([
{ game: 't6', map: 'Town', kind: 'spares' },
{ game: 't5', map: 'Moon', kind: 'watch' }
]);
expect(metricRecommendations([])).toEqual([]);
}); });
}); });
+67 -1
View File
@@ -8,6 +8,7 @@ const report = (days: number) => ({
now: 1000, now: 1000,
since: 0, since: 0,
summary: totals, summary: totals,
running: { average: null, peak: 0, capturedHours: 0 },
points: [], points: [],
maps: [], maps: [],
servers: [], servers: [],
@@ -22,12 +23,20 @@ const report = (days: number) => ({
since: -1000, since: -1000,
until: 0, until: 0,
summary: totals, summary: totals,
running: { average: null, peak: 0, capturedHours: 0 },
firstSampleAt: null, firstSampleAt: null,
lastSampleAt: null, lastSampleAt: null,
partialHistory: true partialHistory: true
}, },
heatmap: { timeZone: 'UTC', cells: [] }, heatmap: { timeZone: 'UTC', cells: [] },
pooledPressure: { value: null, fullMapHours: 0, knownMapHours: 0, coveredMaps: 0, totalMaps: 0 } pooledAvailability: {
value: null,
noEmptyMapHours: 0,
knownMapHours: 0,
unknownMapHours: 0,
coveredMaps: 0,
totalMaps: 0
}
}); });
function setup(fetcher: typeof fetch) { function setup(fetcher: typeof fetch) {
let query = 'days=7'; let query = 'days=7';
@@ -124,6 +133,63 @@ describe('metrics background refresh', () => {
expect(refresh.onLoading).toHaveBeenLastCalledWith(false); expect(refresh.onLoading).toHaveBeenLastCalledWith(false);
}); });
it('accepts availability reports and rejects incomplete running or policy evidence', async () => {
const value = {
...report(7),
maps: [
{
...totals,
game: 't5',
map: 'Moon',
noEmpty: 0,
availabilityObserved: 0,
availabilityUnknown: 1,
noEmptyAtMaximum: 0,
noEmptyBelowMaximum: 0,
policy: {
id: 'moon',
mode: 'create-destroy',
minimum: 1,
maximum: 3,
spares: 1,
pausedReason: null
}
}
],
points: [
{
...totals,
at: 0,
startAt: 0,
endAt: 1000,
partial: true,
capturedHours: 1 / 3600
}
]
};
const invalid = [
{ ...value, running: undefined },
{ ...value, previous: { ...value.previous, running: undefined } },
{ ...value, maps: [{ ...value.maps[0], policy: { id: 'moon' } }] },
{ ...value, maps: [{ ...value.maps[0], availabilityUnknown: undefined }] },
{ ...value, points: [{ ...value.points[0], capturedHours: undefined }] },
{ ...value, pooledAvailability: undefined }
];
const fetcher = vi.fn();
for (const incomplete of invalid) fetcher.mockResolvedValueOnce(Response.json(incomplete));
fetcher.mockResolvedValueOnce(Response.json(value));
const refresh = setup(fetcher);
for (const _ of invalid) {
await refresh.refresh();
expect(refresh.onData).not.toHaveBeenCalled();
expect(refresh.onError).toHaveBeenLastCalledWith(
'The metrics response was incomplete.'
);
}
await refresh.refresh();
expect(refresh.onData).toHaveBeenLastCalledWith(value);
});
it('times out with retry feedback and clears loading', async () => { it('times out with retry feedback and clears loading', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const fetcher = vi.fn( const fetcher = vi.fn(
+241 -24
View File
@@ -31,18 +31,59 @@ describe('historical metrics', () => {
); );
}); });
it('only counts map saturation when every lobby has known capacity and is full', async () => { it('counts no-empty map time from players even without capacity or with unknown lobbies', async () => {
const { recordMetrics, readMetrics } = const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js'); await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 2, 12); const at = Date.UTC(2026, 0, 2, 12);
const sample = { server: 'one', game: 't5', map: 'Moon', players: 4, capacity: 4 }; const sample = { server: 'one', game: 't5', map: 'Moon', players: 4, capacity: 4 };
recordMetrics([sample, { ...sample, server: 'two' }], at); recordMetrics([sample, { ...sample, server: 'two' }], at);
recordMetrics([sample, { ...sample, server: 'two', players: 2 }], at + 30_000); recordMetrics(
recordMetrics([sample, { ...sample, server: 'two', capacity: null }], at + 60_000); [
{ ...sample, players: 1 },
{ ...sample, server: 'two', players: 2 }
],
at + 30_000
);
recordMetrics(
[
{ ...sample, capacity: null },
{ ...sample, server: 'two', capacity: null }
],
at + 60_000
);
recordMetrics([sample, { ...sample, server: 'two', players: null }], at + 90_000); recordMetrics([sample, { ...sample, server: 'two', players: null }], at + 90_000);
const result = readMetrics(1, at + 120_000); const result = readMetrics(1, at + 120_000);
expect(result.maps[0].full * 3600).toBeCloseTo(30); expect(result.maps[0].noEmpty * 3600).toBeCloseTo(120);
expect(result.maps[0].capacityObserved * 3600).toBeCloseTo(60); expect(result.maps[0].availabilityObserved * 3600).toBeCloseTo(120);
expect(result.maps[0].availabilityUnknown).toBe(0);
});
it('treats an empty spare as available and all-unknown map observations as unknown', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 2, 12);
const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null };
recordMetrics([sample, { ...sample, server: 'two', players: 0 }], at);
recordMetrics(
[
{ ...sample, players: null },
{ ...sample, server: 'two', players: 0 }
],
at + 30_000
);
recordMetrics(
[
{ ...sample, players: null },
{ ...sample, server: 'two', players: null }
],
at + 60_000
);
const result = readMetrics(1, at + 90_000);
expect(result.maps[0].noEmpty).toBe(0);
expect(result.maps[0].availabilityObserved * 3600).toBeCloseTo(60);
expect(result.maps[0].availabilityUnknown * 3600).toBeCloseTo(30);
expect(result.pooledAvailability.value).toBe(0);
expect(result.pooledAvailability.unknownMapHours * 3600).toBeCloseTo(30);
}); });
it('clips the reporting window and splits observations across UTC chart boundaries', async () => { it('clips the reporting window and splits observations across UTC chart boundaries', async () => {
@@ -127,7 +168,10 @@ describe('historical metrics', () => {
const later = readMetrics(1, at + 120_000); const later = readMetrics(1, at + 120_000);
expect(later.summary.observed).toBe(0); expect(later.summary.observed).toBe(0);
expect(later.summary.unknown * 3600).toBeCloseTo(90); expect(later.summary.unknown * 3600).toBeCloseTo(90);
expect(later.maps[0].capacityObserved).toBe(0); expect(later.maps[0].availabilityObserved).toBe(0);
expect(later.maps[0].availabilityUnknown * 3600).toBeCloseTo(90);
expect(later.pooledAvailability.value).toBeNull();
expect(initial.running).toEqual({ average: null, peak: 0, capturedHours: 0 });
}); });
it('returns clipped interval boundaries and marks only partial UTC buckets', async () => { it('returns clipped interval boundaries and marks only partial UTC buckets', async () => {
@@ -149,7 +193,7 @@ describe('historical metrics', () => {
).toBe(true); ).toBe(true);
}); });
it('uses complete collection boundaries across fleet changes and keeps map pressure in wall time', async () => { it('uses complete collection boundaries across fleet changes and keeps map availability in wall time', async () => {
const { recordMetrics, readMetrics } = const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js'); await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 2, 23, 59, 40); const at = Date.UTC(2026, 0, 2, 23, 59, 40);
@@ -169,9 +213,11 @@ describe('historical metrics', () => {
).toBeCloseTo(30); ).toBeCloseTo(30);
const town = result.maps.find((map) => map.game === 't6' && map.map === 'Town')!; const town = result.maps.find((map) => map.game === 't6' && map.map === 'Town')!;
expect(town.observed * 3600).toBeCloseTo(60); expect(town.observed * 3600).toBeCloseTo(60);
expect(town.capacityObserved * 3600).toBeCloseTo(30); expect(town.availabilityObserved * 3600).toBeCloseTo(30);
expect(town.full * 3600).toBeCloseTo(30); expect(town.noEmpty * 3600).toBeCloseTo(30);
expect(result.maps.find((map) => map.game === 't5')!.capacityObserved).toBe(0); expect(
result.maps.find((map) => map.game === 't5')!.availabilityObserved * 3600
).toBeCloseTo(30);
for (const field of ['observed', 'active', 'idle', 'unknown', 'playerHours'] as const) { for (const field of ['observed', 'active', 'idle', 'unknown', 'playerHours'] as const) {
expect(result.points.reduce((sum, point) => sum + point[field], 0)).toBeCloseTo( expect(result.points.reduce((sum, point) => sum + point[field], 0)).toBeCloseTo(
result.summary[field] result.summary[field]
@@ -290,7 +336,12 @@ describe('historical metrics', () => {
}); });
expect(result.scopeFirstSampleAt).toBe(since - 30_000); expect(result.scopeFirstSampleAt).toBe(since - 30_000);
expect(result.scopeLatestSampleAt).toBe(now - 30_000); expect(result.scopeLatestSampleAt).toBe(now - 30_000);
expect(result.pooledPressure.value).toBeCloseTo(100 / 3); expect(result.pooledAvailability.value).toBeCloseTo(100 / 3);
expect(result.running.average).toBe(1);
expect(result.running.peak).toBe(1);
expect(result.running.capturedHours * 3600).toBeCloseTo(45);
expect(result.previous.running.average).toBe(1);
expect(result.previous.running.capturedHours * 3600).toBeCloseTo(30);
for (const field of ['observed', 'active', 'idle', 'unknown', 'playerHours'] as const) { for (const field of ['observed', 'active', 'idle', 'unknown', 'playerHours'] as const) {
expect(result.points.reduce((sum, point) => sum + point[field], 0)).toBeCloseTo( expect(result.points.reduce((sum, point) => sum + point[field], 0)).toBeCloseTo(
result.summary[field] result.summary[field]
@@ -301,6 +352,9 @@ describe('historical metrics', () => {
} }
const gameOnly = readMetrics({ ...query, map: null }, now); const gameOnly = readMetrics({ ...query, map: null }, now);
expect(gameOnly.maps.map((map) => map.map).sort()).toEqual(['Moon', 'Town']); expect(gameOnly.maps.map((map) => map.map).sort()).toEqual(['Moon', 'Town']);
expect(gameOnly.running.average).toBeCloseTo(4 / 3);
expect(gameOnly.running.peak).toBe(2);
expect(gameOnly.running.capturedHours * 3600).toBeCloseTo(45);
const missing = readMetrics({ ...query, map: 'Unrecorded' }, now); const missing = readMetrics({ ...query, map: 'Unrecorded' }, now);
expect(missing).toMatchObject({ expect(missing).toMatchObject({
scopeFirstSampleAt: null, scopeFirstSampleAt: null,
@@ -308,7 +362,8 @@ describe('historical metrics', () => {
summary: { observed: 0, unknown: 0 }, summary: { observed: 0, unknown: 0 },
maps: [], maps: [],
servers: [], servers: [],
pooledPressure: { value: null }, pooledAvailability: { value: null },
running: { average: null, peak: 0, capturedHours: 0 },
previous: { firstSampleAt: null, lastSampleAt: null, partialHistory: true } previous: { firstSampleAt: null, lastSampleAt: null, partialHistory: true }
}); });
expect(missing.scopeOptions).toEqual(result.scopeOptions); expect(missing.scopeOptions).toEqual(result.scopeOptions);
@@ -332,6 +387,160 @@ describe('historical metrics', () => {
expect(result.scopeLatestSampleAt).toBe(at - 30_000); expect(result.scopeLatestSampleAt).toBe(at - 30_000);
}); });
it('averages concurrent tracked lobbies over captured wall time, including unknowns and excluding gaps', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 0, 3, 12);
const since = now - 86400_000;
const one = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null };
const two = { ...one, server: 'two', players: 0 };
const three = { ...one, server: 'three', players: null };
const four = { ...one, server: 'four', players: null };
recordMetrics([one, two, three, four], since - 120_000);
recordMetrics([one, two, three], now - 180_000);
recordMetrics([three], now - 150_000);
recordMetrics([one, two], now - 30_000);
const result = readMetrics(1, now);
expect(result.running.average).toBeCloseTo(1.6);
expect(result.running.peak).toBe(3);
expect(result.running.capturedHours * 3600).toBeCloseTo(150);
expect(result.previous.running.average).toBe(4);
expect(result.previous.running.peak).toBe(4);
expect(result.previous.running.capturedHours * 3600).toBeCloseTo(90);
expect(result.points.reduce((sum, point) => sum + point.capturedHours, 0)).toBeCloseTo(
result.running.capturedHours
);
const point = result.points.find((point) => point.capturedHours > 0)!;
expect((point.observed + point.unknown) / point.capturedHours).toBeCloseTo(1.6);
expect(result.summary.observed + result.summary.unknown).toBeCloseTo(
result.running.average! * result.running.capturedHours
);
});
it('matches current policy context through map aliases and prefers the durable owner', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const { db } = await import('../../src/lib/server/db/index.js');
const at = Date.UTC(2026, 0, 3, 12);
const insert = db().prepare(
`INSERT INTO autoscaling_policies
(id,game,map_slug,settings,mode,paused_reason,owns_map,updated_at)
VALUES (?,?,?,?,?,?,?,?)`
);
insert.run(
't5-all',
't5',
'*',
JSON.stringify({ minimum: 1, maximum: 4, spares: 2 }),
'create-destroy',
null,
1,
'now'
);
insert.run(
'moon',
't5',
'moon',
JSON.stringify({ minimum: 1, maximum: 2, spares: 1 }),
'off',
'Operator paused.',
0,
'now'
);
insert.run(
'town',
't6',
'town',
JSON.stringify({ minimum: 2, maximum: 3, spares: 1 }),
'create-destroy',
null,
1,
'now'
);
const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null };
recordMetrics(
[
sample,
{ ...sample, server: 'two', map: 'Kino Der Toten' },
{ ...sample, server: 'three', game: 't6', map: 'Town Survival' },
{ ...sample, server: 'four', game: 't6', map: 'zm_transit' },
{ ...sample, server: 'five', game: 't6', map: 'Moon' },
{ ...sample, server: 'six', map: 'zombie_cosmodrome' },
{ ...sample, server: 'seven', map: 'unknown-map' }
],
at
);
const result = readMetrics(1, at + 30_000);
expect(result.maps.find((map) => map.game === 't5' && map.map === 'Moon')!.policy).toEqual({
id: 't5-all',
mode: 'create-destroy',
minimum: 1,
maximum: 4,
spares: 2,
pausedReason: null
});
expect(result.maps.find((map) => map.map === 'Kino Der Toten')!.policy?.id).toBe('t5-all');
expect(result.maps.find((map) => map.map === 'zombie_cosmodrome')!.policy?.id).toBe(
't5-all'
);
expect(result.maps.find((map) => map.map === 'Town Survival')!.policy?.id).toBe('town');
expect(result.maps.find((map) => map.map === 'zm_transit')!.policy).toBeNull();
expect(
result.maps.find((map) => map.game === 't6' && map.map === 'Moon')!.policy
).toBeNull();
expect(result.maps.find((map) => map.map === 'unknown-map')!.policy).toBeNull();
db().prepare('UPDATE autoscaling_policies SET owns_map=0').run();
expect(
readMetrics(1, at + 30_000).maps.find((map) => map.game === 't5' && map.map === 'Moon')!
.policy
).toMatchObject({ id: 'moon', mode: 'off', pausedReason: 'Operator paused.' });
db().prepare("INSERT INTO settings(key,value) VALUES ('autoscaling_paused','1')").run();
expect(
readMetrics(1, at + 30_000).maps.find((map) => map.game === 't5' && map.map === 'Moon')!
.policy?.pausedReason
).toBe('Autoscaling is globally paused. Operator paused.');
});
it('compares no-empty intervals with the current maximum, counting unknown lobbies in the lineup', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const { db } = await import('../../src/lib/server/db/index.js');
const at = Date.UTC(2026, 0, 3, 12);
db()
.prepare(
`INSERT INTO autoscaling_policies
(id,game,map_slug,settings,mode,owns_map,updated_at) VALUES (?,?,?,?,?,?,?)`
)
.run(
'moon',
't5',
'moon',
JSON.stringify({ minimum: 1, maximum: 2, spares: 1 }),
'create-destroy',
1,
'now'
);
const one = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null };
const two = { ...one, server: 'two', players: null };
recordMetrics([one], at);
recordMetrics([one, two], at + 30_000);
recordMetrics([one, two, { ...one, server: 'three' }], at + 60_000);
recordMetrics([one, { ...two, players: 0 }], at + 90_000);
recordMetrics([{ ...one, players: null }, two], at + 120_000);
const result = readMetrics(1, at + 150_000);
expect(result.maps[0].noEmpty * 3600).toBeCloseTo(90);
expect(result.maps[0].noEmptyAtMaximum * 3600).toBeCloseTo(60);
expect(result.maps[0].noEmptyBelowMaximum * 3600).toBeCloseTo(30);
expect(result.maps[0].availabilityObserved * 3600).toBeCloseTo(120);
expect(result.maps[0].availabilityUnknown * 3600).toBeCloseTo(30);
db()
.prepare("UPDATE autoscaling_policies SET settings=json_set(settings,'$.maximum',4)")
.run();
const revised = readMetrics(1, at + 150_000).maps[0];
expect(revised.noEmptyAtMaximum).toBe(0);
expect(revised.noEmptyBelowMaximum * 3600).toBeCloseTo(90);
});
it('compares adjacent equal windows and splits an observation at their shared boundary', async () => { it('compares adjacent equal windows and splits an observation at their shared boundary', async () => {
const { recordMetrics, readMetrics } = const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js'); await import('../../src/lib/server/fleet/metrics.js');
@@ -391,35 +600,43 @@ describe('historical metrics', () => {
expect(result.previous.summary.unknown).toBeLessThan(24); expect(result.previous.summary.unknown).toBeLessThan(24);
}); });
it('pools pressure by known map-hours rather than server-hours or fleet elapsed time', async () => { it('pools availability by known map-hours rather than lobby-hours or fleet elapsed time', async () => {
const { recordMetrics, readMetrics } = const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js'); await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 3, 12); const at = Date.UTC(2026, 0, 3, 12);
const full = { server: 'one', game: 't5', map: 'Moon', players: 4, capacity: 4 }; const occupied = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: 4 };
recordMetrics( recordMetrics(
[ [
full, occupied,
{ ...full, server: 'two' }, { ...occupied, server: 'two' },
{ ...full, server: 'three', map: 'Town', players: 0 }, { ...occupied, server: 'three', map: 'Town', players: 0 },
{ ...full, server: 'four', map: 'Farm', players: 2, capacity: null } { ...occupied, server: 'four', map: 'Farm', players: 2, capacity: null },
{ ...occupied, server: 'five', map: 'Unknown', players: null }
], ],
at at
); );
const result = readMetrics(1, at + 30_000); const result = readMetrics(1, at + 30_000);
expect(result.pooledPressure).toMatchObject({ value: 50, coveredMaps: 2, totalMaps: 3 }); expect(result.pooledAvailability).toMatchObject({ coveredMaps: 3, totalMaps: 4 });
expect(result.pooledPressure.fullMapHours * 3600).toBeCloseTo(30); expect(result.pooledAvailability.value).toBeCloseTo(200 / 3);
expect(result.pooledPressure.knownMapHours * 3600).toBeCloseTo(60); expect(result.pooledAvailability.noEmptyMapHours * 3600).toBeCloseTo(60);
expect(result.pooledAvailability.knownMapHours * 3600).toBeCloseTo(90);
expect(result.pooledAvailability.unknownMapHours * 3600).toBeCloseTo(30);
expect(result.summary.observed * 3600).toBeCloseTo(120); expect(result.summary.observed * 3600).toBeCloseTo(120);
const empty = readMetrics( const empty = readMetrics(
parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Town' })), parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Town' })),
at + 30_000 at + 30_000
); );
expect(empty.pooledPressure.value).toBe(0); expect(empty.pooledAvailability.value).toBe(0);
const missing = readMetrics( const missingCapacity = readMetrics(
parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Farm' })), parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Farm' })),
at + 30_000 at + 30_000
); );
expect(missing.pooledPressure.value).toBeNull(); expect(missingCapacity.pooledAvailability.value).toBe(100);
const unknown = readMetrics(
parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Unknown' })),
at + 30_000
);
expect(unknown.pooledAvailability.value).toBeNull();
}); });
it.each([ it.each([
+56
View File
@@ -34,3 +34,59 @@ it('runs reports off the request thread and invalidates cached history after wri
expect(changed).toEqual(readMetrics(query, now)); expect(changed).toEqual(readMetrics(query, now));
expect(changed.summary.playerHours).toBeCloseTo((await first).summary.playerHours * 3); expect(changed.summary.playerHours).toBeCloseTo((await first).summary.playerHours * 3);
}); });
it('refreshes policy signals after policy edits and global pauses without new samples', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { recordMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const { metricsReport } = await import('../../src/lib/server/reports/index.js');
const now = Date.UTC(2026, 9, 4, 12);
vi.spyOn(Date, 'now').mockReturnValue(now);
recordMetrics(
[{ server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null }],
now - 60_000
);
const query = parseMetricsQuery(new URLSearchParams('days=1'));
expect((await metricsReport(query)).maps[0].policy).toBeNull();
const settings = {
game: 't5',
mapSlug: '*',
mode: 'create-destroy',
minimum: 1,
maximum: 1,
spares: 1,
emptyMinutes: 15
};
db()
.prepare(
`INSERT INTO autoscaling_policies
(id,game,map_slug,settings,mode,owns_map,updated_at) VALUES(?,?,?,?,?,1,?)`
)
.run(
'moon-policy',
't5',
'*',
JSON.stringify(settings),
settings.mode,
new Date(now).toISOString()
);
const atMaximum = (await metricsReport(query)).maps[0];
expect(atMaximum.policy?.maximum).toBe(1);
expect(atMaximum.noEmptyAtMaximum).toBeCloseTo(1 / 60);
db()
.prepare('UPDATE autoscaling_policies SET settings=? WHERE id=?')
.run(JSON.stringify({ ...settings, maximum: 3 }), 'moon-policy');
const belowMaximum = (await metricsReport(query)).maps[0];
expect(belowMaximum.policy?.maximum).toBe(3);
expect(belowMaximum.noEmptyAtMaximum).toBe(0);
expect(belowMaximum.noEmptyBelowMaximum).toBeCloseTo(1 / 60);
db()
.prepare(
"INSERT INTO settings(key,value) VALUES('autoscaling_paused','1') ON CONFLICT(key) DO UPDATE SET value='1'"
)
.run();
expect((await metricsReport(query)).maps[0].policy?.pausedReason).toBeTruthy();
db().prepare("UPDATE settings SET value='0' WHERE key='autoscaling_paused'").run();
expect((await metricsReport(query)).maps[0].policy?.pausedReason).toBeNull();
db().prepare("DELETE FROM autoscaling_policies WHERE id='moon-policy'").run();
expect((await metricsReport(query)).maps[0].policy).toBeNull();
});
+2 -3
View File
@@ -228,9 +228,8 @@ describe('useful synthetic fixture data', () => {
expect(metrics.samples).toBe(7 * 24 * 60); expect(metrics.samples).toBe(7 * 24 * 60);
expect(metrics.newest).toBe(NOW); expect(metrics.newest).toBe(NOW);
expect(metrics.newest - metrics.oldest).toBe((metrics.samples - 1) * 60_000); expect(metrics.newest - metrics.oldest).toBe((metrics.samples - 1) * 60_000);
expect( expect(count('SELECT COUNT(*) n FROM metric_samples WHERE players<0 OR players>4')).toBe(0);
count('SELECT COUNT(*) n FROM metric_samples WHERE players<0 OR players>capacity') expect(count('SELECT COUNT(*) n FROM metric_samples WHERE capacity IS NOT NULL')).toBe(0);
).toBe(0);
}); });
it('supports actual leaderboard pagination and evidence for every exact squad size', async () => { it('supports actual leaderboard pagination and evidence for every exact squad size', async () => {
+141 -26
View File
@@ -24,8 +24,12 @@ const map = (i: number, changes: Partial<MapMetrics> = {}): MapMetrics => ({
idle: 5, idle: 5,
unknown: 0, unknown: 0,
playerHours: i, playerHours: i,
full: 1, noEmpty: 1,
capacityObserved: 4, availabilityObserved: 4,
availabilityUnknown: 0,
noEmptyAtMaximum: 0,
noEmptyBelowMaximum: 0,
policy: null,
...changes ...changes
}); });
const server = (i: number): ServerMetrics => ({ const server = (i: number): ServerMetrics => ({
@@ -52,23 +56,33 @@ function report(): MetricsData {
scopeFirstSampleAt: now - 14 * 86400000, scopeFirstSampleAt: now - 14 * 86400000,
scopeLatestSampleAt: now, scopeLatestSampleAt: now,
summary, summary,
running: { average: 12, peak: 24, capturedHours: 1 },
maps: Array.from({ length: 24 }, (_, i) => map(i + 1)), maps: Array.from({ length: 24 }, (_, i) => map(i + 1)),
servers: Array.from({ length: 24 }, (_, i) => server(i + 1)), servers: Array.from({ length: 24 }, (_, i) => server(i + 1)),
points: [ points: [
{ ...summary, at: now - 86400000, startAt: now - 86400000, endAt: now, partial: true } {
...summary,
at: now - 86400000,
startAt: now - 86400000,
endAt: now,
partial: true,
capturedHours: 1
}
], ],
previous: { previous: {
since: now - 14 * 86400000, since: now - 14 * 86400000,
until: now - 7 * 86400000, until: now - 7 * 86400000,
summary: { ...summary, active: 4, idle: 6, playerHours: 10 }, summary: { ...summary, active: 4, idle: 6, playerHours: 10 },
running: { average: 12, peak: 24, capturedHours: 1 },
firstSampleAt: now - 14 * 86400000, firstSampleAt: now - 14 * 86400000,
lastSampleAt: now - 7 * 86400000, lastSampleAt: now - 7 * 86400000,
partialHistory: true partialHistory: true
}, },
pooledPressure: { pooledAvailability: {
value: 25, value: 25,
fullMapHours: 1, noEmptyMapHours: 1,
knownMapHours: 4, knownMapHours: 4,
unknownMapHours: 0,
coveredMaps: 24, coveredMaps: 24,
totalMaps: 24 totalMaps: 24
}, },
@@ -95,6 +109,9 @@ it('shows a summary and one paginated breakdown, preserving scope on range links
expect(body).toContain('game=t5&amp;days=30&amp;view=maps'); expect(body).toContain('game=t5&amp;days=30&amp;view=maps');
expect(body).toContain('Partial previous history'); expect(body).toContain('Partial previous history');
expect(body).toContain('About this report'); expect(body).toContain('About this report');
expect(body).toContain('Average lobbies running:');
expect(body.replace(/\s+/g, ' ')).toContain('Captured wall time:');
expect(body).not.toContain('Percentage points show');
expect(body).not.toContain('Explore this period'); expect(body).not.toContain('Explore this period');
expect(body).toContain('Updates every minute'); expect(body).toContain('Updates every minute');
expect(body).not.toContain('>Refresh<'); expect(body).not.toContain('>Refresh<');
@@ -107,28 +124,44 @@ it('shows a summary and one paginated breakdown, preserving scope on range links
expect(body).not.toContain('class="weekly'); expect(body).not.toContain('class="weekly');
}); });
it('keeps percentage points, relative changes and unavailable capacity distinct', () => { it('shows concurrent lobbies and empty spare share with unknown lobby-hours in the denominator', () => {
const data = report(); const data = report();
let body = render(MetricsSummary, { props: { report: data } }).body; let body = render(MetricsSummary, { props: { report: data } }).body;
expect(body).toContain('+10 percentage points'); expect(body).toContain('12 avg');
expect(body).toContain('Peak 24 lobbies at once');
expect(body.replace(/\s+/g, ' ')).toContain(
'5 h of 12 h captured lobby-hours were empty spares (41.7%)'
);
expect(body).toContain('+100%'); expect(body).toContain('+100%');
data.pooledPressure = { expect(body).toContain('Time with no empty lobby');
expect(body).toContain('25.0%');
expect(body).not.toContain('capacity data');
data.pooledAvailability = {
value: null, value: null,
fullMapHours: 0, noEmptyMapHours: 0,
knownMapHours: 0, knownMapHours: 0,
unknownMapHours: 4,
coveredMaps: 0, coveredMaps: 0,
totalMaps: 24 totalMaps: 24
}; };
data.maps = [map(1, { capacityObserved: 0 })]; data.maps = [map(1, { availabilityObserved: 0, availabilityUnknown: 4 })];
data.previous.summary.playerHours = 0; data.previous.summary.playerHours = 0;
body = render(MetricsSummary, { props: { report: data } }).body; body = render(MetricsSummary, { props: { report: data } }).body;
expect(body).toContain('Capacity data unavailable'); expect(body).toContain('Lobby availability unavailable');
expect(body).toContain('New · no previous player-hours'); expect(body).toContain('New · no previous player-hours');
expect(body.replace(/\s+/g, ' ')).toContain('1 of 1 maps lack capacity data');
expect(body).not.toContain('Infinity'); expect(body).not.toContain('Infinity');
data.summary.observed = 0; data.summary = { observed: 0, active: 0, idle: 0, playerHours: 0, unknown: 12 };
body = render(MetricsSummary, { props: { report: data } }).body; body = render(MetricsSummary, { props: { report: data } }).body;
expect(body).toContain('Comparison unavailable'); expect(body).toContain('Comparison unavailable');
expect(body).toContain('12 avg');
expect(body.replace(/\s+/g, ' ')).toContain(
'0 h of 12 h captured lobby-hours were empty spares (0.0%)'
);
data.running = { average: null, peak: 0, capturedHours: 0 };
data.summary.unknown = 0;
body = render(MetricsSummary, { props: { report: data } }).body;
expect(body).toContain('Peak unavailable');
expect(body).not.toContain('Peak 0 lobbies');
}); });
it('searches and sorts the complete map set before pagination and clamps invalid pages', () => { it('searches and sorts the complete map set before pagination and clamps invalid pages', () => {
@@ -147,14 +180,20 @@ it('searches and sorts the complete map set before pagination and clamps invalid
expect(metricPage([], '20')).toMatchObject({ page: 1, start: 0, end: 0, total: 0 }); expect(metricPage([], '20')).toMatchObject({ page: 1, start: 0, end: 0, total: 0 });
}); });
it('keeps missing capacity last when sorting and never presents it as zero full time', () => { it('keeps unknown availability last when sorting and distinguishes it from known empty lobbies', () => {
scope('?mapSort=full&mapDirection=asc'); scope('?mapSort=noEmpty&mapDirection=asc');
const maps = [map(1, { capacityObserved: 0 }), map(2, { full: 0 }), map(3)]; const maps = [
map(1, { availabilityObserved: 0, availabilityUnknown: 4 }),
map(2, { noEmpty: 0 }),
map(3)
];
const body = render(MetricsTables, { props: { maps, servers: [] } }).body; const body = render(MetricsTables, { props: { maps, servers: [] } }).body;
expect(body.indexOf('Map 02')).toBeLessThan(body.indexOf('Map 03')); expect(body.indexOf('Map 02')).toBeLessThan(body.indexOf('Map 03'));
expect(body.indexOf('Map 03')).toBeLessThan(body.indexOf('Map 01')); expect(body.indexOf('Map 03')).toBeLessThan(body.indexOf('Map 01'));
expect(body).toContain('0.0%'); expect(body).toContain('0.0%');
expect(body).toContain('Capacity data unavailable'); expect(body).toContain('Lobby availability unavailable');
expect(body).toContain('No empty lobby');
expect(body).not.toContain('All lobbies full');
}); });
it('preserves instance return context and distinguishes historic names without current links', () => { it('preserves instance return context and distinguishes historic names without current links', () => {
@@ -170,18 +209,18 @@ it('preserves instance return context and distinguishes historic names without c
expect(body).not.toContain('Open current server'); expect(body).not.toContain('Open current server');
}); });
it('highlights at most three supported trials and collapses tentative findings', () => { it('highlights at most three supported policy changes and collapses tentative findings', () => {
const data = report(); const data = report();
data.maps.push(map(25, { observed: 0.1, active: 0.1, idle: 0, capacityObserved: 0 })); data.maps.push(map(25, { observed: 0.1, active: 0.1, idle: 0, availabilityObserved: 0 }));
const body = render(MetricsExplore, { props: { report: data } }).body; const body = render(MetricsExplore, { props: { report: data } }).body;
const fold = body.indexOf('Other findings'); const fold = body.indexOf('Other findings');
expect(body.slice(0, fold).match(/class="recommendation /g)).toHaveLength(3); expect(body.slice(0, fold).match(/class="recommendation /g)).toHaveLength(3);
expect(body.indexOf('Map 25')).toBeGreaterThan(fold); expect(body.indexOf('Map 25')).toBeGreaterThan(fold);
expect(body).toContain('Limited evidence'); expect(body).toContain('Limited evidence');
expect(body).not.toMatch(/<details[^>]*open/); expect(body).not.toMatch(/<details[^>]*open/);
data.maps = [map(1, { capacityObserved: 0 })]; data.maps = [map(1, { availabilityObserved: 0 })];
expect(render(MetricsExplore, { props: { report: data } }).body).toContain( expect(render(MetricsExplore, { props: { report: data } }).body).toContain(
'No hosting trials meet the evidence threshold' 'No policy changes meet the evidence threshold'
); );
}); });
@@ -196,20 +235,65 @@ it('keeps accessible chart selection, partial periods and gaps while collapsing
active: 0, active: 0,
idle: 0, idle: 0,
unknown: 0, unknown: 0,
playerHours: 0 playerHours: 0,
capturedHours: 0
}); });
const body = render(MetricsChart, { const body = render(MetricsChart, {
props: { points: data.points, days: data.days, timeZone: data.scope.timeZone } props: { points: data.points, days: data.days, timeZone: data.scope.timeZone }
}).body; }).body;
expect(body).toContain('aria-label="Selected period summary"'); expect(body).toContain('aria-label="Selected period summary"');
expect(body).toContain('No captured time · not evidence of idle servers'); expect(body).toContain('No captured time · not evidence of empty lobbies');
expect(body).toContain('tabindex="0"'); expect(body).toContain('tabindex="0"');
expect(body).toContain('Inspect a period'); expect(body).toContain('Inspect a period');
expect(body).toContain('Partial period'); expect(body).toContain('Partial period');
expect(body).not.toMatch(/<details[^>]*open/); expect(body).not.toMatch(/<details[^>]*open/);
expect(body).toContain('Weekdays show when each period starts in America/New York.'); expect(body).toContain('Weekdays show when each period starts in America/New York.');
expect(body).toContain('Daily periods are 24-hour windows.'); expect(body).toContain('Daily periods are 24-hour windows.');
expect(body).toContain('Server activity over time'); expect(body).toContain('Lobbies running over time');
expect(body).toContain('Average lobbies running');
expect(body).toContain('5 with players · 5 empty spares · 2 unknown on average');
});
it('keeps average lobby bar heights comparable across partial periods and collection gaps', () => {
const data = report();
const point = data.points[0];
data.points = [
{
...point,
observed: 48,
active: 12,
idle: 36,
unknown: 24,
capturedHours: 12,
partial: false
},
{
...point,
at: data.now,
startAt: data.now,
endAt: data.now + 2 * 3600000,
observed: 8,
active: 2,
idle: 6,
unknown: 4,
capturedHours: 2,
partial: true
}
];
const body = render(MetricsChart, {
props: { points: data.points, days: data.days, timeZone: data.scope.timeZone }
}).body;
const bars = body.match(/<button class="bar\b[\s\S]*?<\/button>/g) ?? [];
expect(bars).toHaveLength(2);
const heights = bars.map((bar) =>
[...bar.matchAll(/style="height:\s*([^"]+)"/g)].map((match) => match[1])
);
expect(heights[0]).toHaveLength(3);
expect(heights[0]).toEqual(heights[1]);
expect(heights[0]).toContain('50%;');
expect(
bars.every((bar) => bar.includes('1 with players · 3 empty spares · 2 unknown on average'))
).toBe(true);
}); });
it('uses shallow URL state for the selected breakdown while retaining the common summary', () => { it('uses shallow URL state for the selected breakdown while retaining the common summary', () => {
@@ -225,15 +309,46 @@ it('uses shallow URL state for the selected breakdown while retaining the common
props: { data: { ...report(), user: null, setupRequired: false } } props: { data: { ...report(), user: null, setupRequired: false } }
}).body; }).body;
expect(body).toContain('id="metrics-explore-title"'); expect(body).toContain('id="metrics-explore-title"');
expect(body).toContain('Time all lobbies were full'); expect(body).toContain('Time with no empty lobby');
expect(body).toContain('Autoscaling signals');
expect(body).not.toContain('aria-label="Map metrics table"'); expect(body).not.toContain('aria-label="Map metrics table"');
expect(body).not.toContain('aria-label="Instance metrics table"'); expect(body).not.toContain('aria-label="Instance metrics table"');
}); });
it('filters other insights without hiding the highlighted supported trials', () => { it('filters other signals without hiding the highlighted supported policy changes', () => {
scope('?view=insights&insightQ=Map+24&insightSort=map'); scope('?view=insights&insightQ=Map+24&insightSort=map');
const body = render(MetricsExplore, { props: { report: report() } }).body; const body = render(MetricsExplore, { props: { report: report() } }).body;
expect(body).toContain('Map 01'); expect(body).toContain('Map 01');
expect(body).toContain('Map 24'); expect(body).toContain('Map 24');
expect(body).not.toContain('Map 23'); expect(body).not.toContain('Map 23');
}); });
it('links autoscaling signals to current policies and explains their historical comparison', () => {
const data = report();
data.maps = [
map(1, {
noEmptyAtMaximum: 0.75,
noEmptyBelowMaximum: 0.25,
policy: {
id: 'policy-1',
mode: 'create-destroy',
minimum: 1,
maximum: 3,
spares: 1,
pausedReason: null
}
})
];
let body = render(MetricsExplore, { props: { report: data } }).body;
expect(body).toContain('href="/autoscaling#policy-1"');
expect(body).toContain('Review policy');
expect(body).toContain('Current policy: 1–3 lobbies, 1 spare');
expect(body.replace(/\s+/g, ' ')).toContain(
'Current settings are applied to historical lobby counts'
);
expect(body).not.toContain('player limits, and observation');
data.maps[0].policy = null;
body = render(MetricsExplore, { props: { report: data } }).body;
expect(body).toContain('href="/autoscaling"');
expect(body).toContain('Add policy');
});
+9
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
compareMetricValues, compareMetricValues,
metricAverageLobbies,
metricDuration, metricDuration,
metricNumber, metricNumber,
metricPercent, metricPercent,
@@ -8,6 +9,14 @@ import {
} from '../../src/lib/ui/metrics.js'; } from '../../src/lib/ui/metrics.js';
describe('metric presentation', () => { describe('metric presentation', () => {
it('normalizes partial buckets by captured wall time and preserves unknown lobby counts', () => {
expect(metricAverageLobbies(14 * 24, 24)).toBe(14);
expect(metricAverageLobbies(14 * 0.5, 0.5)).toBe(14);
expect(metricAverageLobbies(12 + 2, 1)).toBe(14);
expect(metricAverageLobbies(0, 1)).toBe(0);
expect(metricAverageLobbies(0, 0)).toBeNull();
expect(metricAverageLobbies(4, Number.NaN)).toBeNull();
});
it('distinguishes unavailable evidence, zero, and tiny nonzero values', () => { it('distinguishes unavailable evidence, zero, and tiny nonzero values', () => {
expect(metricPercent(0, 0)).toBe('—'); expect(metricPercent(0, 0)).toBe('—');
expect(metricPercent(0, 1)).toBe('0.0%'); expect(metricPercent(0, 1)).toBe('0.0%');