Preserve shortage signals during global pause (Refs #137)
CI / checks (push) Successful in 4m47s

This commit is contained in:
2026-10-10 16:11:24 -04:00
parent 33da4482a6
commit ea932ec563
14 changed files with 154 additions and 26 deletions
+9 -2
View File
@@ -34,7 +34,8 @@ the URL; refreshes preserve table controls and chart selection.
The timeline defaults to **Average lobbies running**, split into **With players**, The timeline defaults to **Average lobbies running**, split into **With players**,
**Empty spare**, and **Unknown**. Each bucket divides those lobby-hours by its **Empty spare**, and **Unknown**. Each bucket divides those lobby-hours by its
captured wall time, so changing lobby counts remain visible. Collection gaps are captured wall time, so changing lobby counts remain visible. Collection gaps are
excluded from that denominator. Switch to the share view to compare proportions, excluded from that denominator. The average-lobby axis uses whole-number ticks;
period details preserve fractional averages. Switch to the share view to compare proportions,
or hours to compare absolute lobby time. Periods without captured time remain gaps. or hours to compare absolute lobby time. Periods without captured time remain gaps.
The timeline has labelled axes and marks partial edge periods. Select a bar or use The timeline has labelled axes and marks partial edge periods. Select a bar or use
@@ -77,6 +78,11 @@ Game-wide policies apply independently to each supported map variant. Each signa
links to the policy on **Fleet → Autoscaling**; a map without a policy gets a link links to the policy on **Fleet → Autoscaling**; a map without a policy gets a link
to add one. Signals are advisory and never change a policy or a lobby. to add one. Signals are advisory and never change a policy or a lobby.
A global pause appears once above the signals. Historical shortage signals remain
visible while the fleet is paused, including suggestions to review the maximum
or spare target. Review these findings and current settings before resuming.
An individual policy's own pause or disabled state still prompts a policy review.
| Recorded condition | Suggested action | | 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 at or above the current maximum | Review raising the policy maximum. |
@@ -137,5 +143,6 @@ before scope filtering, so samples from other groups still end a batch interval.
their own `capturedHours`. Map rows expose `noEmpty`, `availabilityObserved`, and their own `capturedHours`. Map rows expose `noEmpty`, `availabilityObserved`, and
`availabilityUnknown` durations, plus current `policy` context and `availabilityUnknown` durations, plus current `policy` context and
`noEmptyAtMaximum` / `noEmptyBelowMaximum` durations. `pooledAvailability` combines `noEmptyAtMaximum` / `noEmptyBelowMaximum` durations. `pooledAvailability` combines
map durations and coverage. Policy edits and global pause changes invalidate map durations and coverage. `autoscalingPaused` reports the current global pause
separately from each policy's own `pausedReason`. Policy edits and global pause changes invalidate
cached metrics reports. cached metrics reports.
+2
View File
@@ -130,6 +130,8 @@ export interface MetricsData {
firstRetainedSampleAt: number | null; firstRetainedSampleAt: number | null;
latestSampleAt: number | null; latestSampleAt: number | null;
registeredServerCount: number; registeredServerCount: number;
/** Current fleet-wide pause, separate from each policy's own state. */
autoscalingPaused: boolean;
scope: MetricsScope; scope: MetricsScope;
scopeOptions: { games: string[]; maps: { game: string; map: string }[] }; scopeOptions: { games: string[]; maps: { game: string; map: string }[] };
scopeFirstSampleAt: number | null; scopeFirstSampleAt: number | null;
+2 -5
View File
@@ -123,11 +123,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
minimum, minimum,
maximum, maximum,
spares, spares,
pausedReason: globalPaused pausedReason: row.paused_reason
? ['Autoscaling is globally paused.', row.paused_reason]
.filter(Boolean)
.join(' ')
: row.paused_reason
} }
}; };
}); });
@@ -441,6 +437,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
since, since,
now, now,
...metadata, ...metadata,
autoscalingPaused: globalPaused,
scope, scope,
scopeOptions, scopeOptions,
summary, summary,
@@ -6,6 +6,7 @@
import { import {
metricDuration, metricDuration,
metricAverageLobbies, metricAverageLobbies,
metricLobbyAxis,
metricNumber, metricNumber,
metricPercent, metricPercent,
metricPeriodLabels metricPeriodLabels
@@ -34,8 +35,8 @@
}); });
const selected = $derived(points[selectedIndex]); const selected = $derived(points[selectedIndex]);
const selectedValue = $derived(selected ? String(selected.at) : ''); const selectedValue = $derived(selected ? String(selected.at) : '');
const ceiling = $derived.by(() => { const axis = $derived.by(() => {
if (mode === 'percent') return 100; if (mode === 'percent') return { ceiling: 100, ticks: [100, 75, 50, 25, 0] };
const max = Math.max( const max = Math.max(
0, 0,
...points.map((point) => ...points.map((point) =>
@@ -45,15 +46,20 @@
: point.observed + point.unknown : point.observed + point.unknown
) )
); );
if (!max) return 1; if (mode === 'average') return metricLobbyAxis(max);
const magnitude = 10 ** Math.floor(Math.log10(max)); const magnitude = max ? 10 ** Math.floor(Math.log10(max)) : 1;
return Math.ceil(max / (magnitude / 2)) * (magnitude / 2); const ceiling = max ? Math.ceil(max / (magnitude / 2)) * (magnitude / 2) : 1;
return {
ceiling,
ticks: [1, 0.75, 0.5, 0.25, 0].map((fraction) => ceiling * fraction)
};
}); });
const ceiling = $derived(axis.ceiling);
const tick = new Intl.NumberFormat('en-US', { maximumSignificantDigits: 3 }); const tick = new Intl.NumberFormat('en-US', { maximumSignificantDigits: 3 });
const ticks = $derived( const ticks = $derived(
[1, 0.75, 0.5, 0.25, 0].map((fraction) => ({ axis.ticks.map((value) => ({
fraction, fraction: value / ceiling,
value: `${tick.format(ceiling * fraction)}${mode === 'percent' ? '%' : ''}` value: `${tick.format(value)}${mode === 'percent' ? '%' : ''}`
})) }))
); );
const periodOptions = $derived( const periodOptions = $derived(
@@ -70,6 +70,10 @@
are applied to historical lobby counts; these are not records of the policy active then. No 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. reported empty lobby does not prove that players were turned away.
</p> </p>
{#if report.autoscalingPaused}<p class="notice">
Autoscaling is globally paused. Review the shortage signals and current settings before
resuming in <a href="/autoscaling">Autoscaling</a>.
</p>{/if}
{#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 policy changes 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">
@@ -124,8 +128,9 @@
empty. Player limits are not needed for these signals. empty. Player limits are not needed for these signals.
</li> </li>
<li> <li>
Maps without a current policy suggest adding one. Paused or disabled policies prompt Maps without a current policy suggest adding one. Individually paused or disabled
a review before resuming or enabling them. policies prompt a review before resuming or enabling them. A global pause does not
replace shortage signals.
</li> </li>
<li> <li>
All-unknown occupancy prompts a reporting check. Other cases suggest watching All-unknown occupancy prompts a reporting check. Other cases suggest watching
@@ -54,7 +54,7 @@
); );
} }
function description(cell: Cell) { function description(cell: Cell) {
return `${weekdays[cell.weekday]}, ${hourLabel(cell.hour)}: ${cell.observed ? `${metricPercent(cell.active, cell.observed)} with players during ${metricDuration(cell.observed)} known server time` : cell.unknown ? `${metricDuration(cell.unknown)} unknown server time` : 'No captured time'}. ${cell.distinctDates} distinct dates.`; return `${weekdays[cell.weekday]}, ${hourLabel(cell.hour)}: ${cell.observed ? `${metricPercent(cell.active, cell.observed)} with players during ${metricDuration(cell.observed)} known lobby time` : cell.unknown ? `${metricDuration(cell.unknown)} unknown lobby time` : 'No captured time'}. ${cell.distinctDates} distinct dates.`;
} }
function select(day: number, hour: number) { function select(day: number, hour: number) {
selectedKey = day * 24 + hour; selectedKey = day * 24 + hour;
@@ -102,7 +102,7 @@
<div> <div>
<h2 id={`${id}-heatmap-title`}>Activity by weekday and hour</h2> <h2 id={`${id}-heatmap-title`}>Activity by weekday and hour</h2>
<p> <p>
Time with players as a share of known server time, grouped in {heatmap.timeZone.replaceAll( Time with players as a share of known lobby time, grouped in {heatmap.timeZone.replaceAll(
'_', '_',
' ' ' '
)}. )}.
@@ -233,7 +233,7 @@
</dd> </dd>
</div> </div>
<div> <div>
<dt>Unknown server time</dt> <dt>Unknown lobby time</dt>
<dd>{metricDuration(selected.unknown)}</dd> <dd>{metricDuration(selected.unknown)}</dd>
</div> </div>
<div> <div>
+1
View File
@@ -55,6 +55,7 @@ function validReport(value: unknown): value is MetricsData {
return false; return false;
if ( if (
!numeric(value.registeredServerCount) || !numeric(value.registeredServerCount) ||
typeof value.autoscalingPaused !== 'boolean' ||
![ ![
'firstRetainedSampleAt', 'firstRetainedSampleAt',
'latestSampleAt', 'latestSampleAt',
+16
View File
@@ -38,6 +38,22 @@ export function metricAverageLobbies(lobbyHours: number, capturedHours: number):
return lobbyHours / capturedHours; return lobbyHours / capturedHours;
} }
/** Use up to four whole-lobby intervals while retaining fractional averages in the data. */
export function metricLobbyAxis(maximumLobbies: number) {
const maximum = Number.isFinite(maximumLobbies) ? Math.max(1, maximumLobbies) : 1;
const targetStep = Math.max(1, maximum / 4);
const magnitude = 10 ** Math.floor(Math.log10(targetStep));
const step =
[1, 2, 5, 10]
.map((multiple) => multiple * magnitude)
.find((value) => value >= targetStep) ?? 10 * magnitude;
const intervals = Math.ceil(maximum / step);
return {
ceiling: intervals * step,
ticks: Array.from({ length: intervals + 1 }, (_, index) => (intervals - index) * step)
};
}
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;
+1 -1
View File
@@ -336,7 +336,7 @@
<summary>About this report</summary> <summary>About this report</summary>
<div class="context"> <div class="context">
{#if captured > 0}<span {#if captured > 0}<span
><strong>{metricDuration(report.summary.observed)}</strong> known server time</span ><strong>{metricDuration(report.summary.observed)}</strong> known lobby time</span
><span ><span
><strong>{metricDuration(report.summary.unknown)}</strong> unknown status</span ><strong>{metricDuration(report.summary.unknown)}</strong> unknown status</span
>{/if} >{/if}
+2
View File
@@ -15,6 +15,7 @@ const report = (days: number) => ({
scope: { game: null, map: null, timeZone: 'UTC' }, scope: { game: null, map: null, timeZone: 'UTC' },
scopeOptions: { games: [], maps: [] }, scopeOptions: { games: [], maps: [] },
registeredServerCount: 0, registeredServerCount: 0,
autoscalingPaused: false,
firstRetainedSampleAt: null, firstRetainedSampleAt: null,
latestSampleAt: null, latestSampleAt: null,
scopeFirstSampleAt: null, scopeFirstSampleAt: null,
@@ -168,6 +169,7 @@ describe('metrics background refresh', () => {
] ]
}; };
const invalid = [ const invalid = [
{ ...value, autoscalingPaused: undefined },
{ ...value, running: undefined }, { ...value, running: undefined },
{ ...value, previous: { ...value.previous, running: undefined } }, { ...value, previous: { ...value.previous, running: undefined } },
{ ...value, maps: [{ ...value.maps[0], policy: { id: 'moon' } }] }, { ...value, maps: [{ ...value.maps[0], policy: { id: 'moon' } }] },
+8 -3
View File
@@ -494,11 +494,16 @@ describe('historical metrics', () => {
readMetrics(1, at + 30_000).maps.find((map) => map.game === 't5' && map.map === 'Moon')! readMetrics(1, at + 30_000).maps.find((map) => map.game === 't5' && map.map === 'Moon')!
.policy .policy
).toMatchObject({ id: 'moon', mode: 'off', pausedReason: 'Operator paused.' }); ).toMatchObject({ id: 'moon', mode: 'off', pausedReason: 'Operator paused.' });
const beforeGlobalPause = readMetrics(1, at + 30_000);
expect(beforeGlobalPause.autoscalingPaused).toBe(false);
db().prepare("INSERT INTO settings(key,value) VALUES ('autoscaling_paused','1')").run(); db().prepare("INSERT INTO settings(key,value) VALUES ('autoscaling_paused','1')").run();
const globallyPaused = readMetrics(1, at + 30_000);
expect(globallyPaused.autoscalingPaused).toBe(true);
expect(globallyPaused.maps).toEqual(beforeGlobalPause.maps);
expect( expect(
readMetrics(1, at + 30_000).maps.find((map) => map.game === 't5' && map.map === 'Moon')! globallyPaused.maps.find((map) => map.game === 't5' && map.map === 'Moon')!.policy
.policy?.pausedReason ?.pausedReason
).toBe('Autoscaling is globally paused. Operator paused.'); ).toBe('Operator paused.');
}); });
it('compares no-empty intervals with the current maximum, counting unknown lobbies in the lineup', async () => { it('compares no-empty intervals with the current maximum, counting unknown lobbies in the lineup', async () => {
+6 -2
View File
@@ -84,9 +84,13 @@ it('refreshes policy signals after policy edits and global pauses without new sa
"INSERT INTO settings(key,value) VALUES('autoscaling_paused','1') ON CONFLICT(key) DO UPDATE SET value='1'" "INSERT INTO settings(key,value) VALUES('autoscaling_paused','1') ON CONFLICT(key) DO UPDATE SET value='1'"
) )
.run(); .run();
expect((await metricsReport(query)).maps[0].policy?.pausedReason).toBeTruthy(); const globallyPaused = await metricsReport(query);
expect(globallyPaused.autoscalingPaused).toBe(true);
expect(globallyPaused.maps[0]).toEqual(belowMaximum);
db().prepare("UPDATE settings SET value='0' WHERE key='autoscaling_paused'").run(); db().prepare("UPDATE settings SET value='0' WHERE key='autoscaling_paused'").run();
expect((await metricsReport(query)).maps[0].policy?.pausedReason).toBeNull(); const resumed = await metricsReport(query);
expect(resumed.autoscalingPaused).toBe(false);
expect(resumed.maps).toEqual(globallyPaused.maps);
db().prepare("DELETE FROM autoscaling_policies WHERE id='moon-policy'").run(); db().prepare("DELETE FROM autoscaling_policies WHERE id='moon-policy'").run();
expect((await metricsReport(query)).maps[0].policy).toBeNull(); expect((await metricsReport(query)).maps[0].policy).toBeNull();
}); });
+64
View File
@@ -51,6 +51,7 @@ function report(): MetricsData {
firstRetainedSampleAt: now - 14 * 86400000, firstRetainedSampleAt: now - 14 * 86400000,
latestSampleAt: now, latestSampleAt: now,
registeredServerCount: 24, registeredServerCount: 24,
autoscalingPaused: false,
scope: { game: null, map: null, timeZone: 'America/New_York' }, scope: { game: null, map: null, timeZone: 'America/New_York' },
scopeOptions: { games: ['t5'], maps: [] }, scopeOptions: { games: ['t5'], maps: [] },
scopeFirstSampleAt: now - 14 * 86400000, scopeFirstSampleAt: now - 14 * 86400000,
@@ -224,6 +225,45 @@ it('highlights at most three supported policy changes and collapses tentative fi
); );
}); });
it('shows a global pause once while keeping Kino’s shortage ahead of quiet maps', () => {
const data = report();
data.autoscalingPaused = true;
const policy = {
id: 't5-all',
mode: 'create-destroy' as const,
minimum: 1,
maximum: 3,
spares: 1,
pausedReason: null
};
data.maps = [
map(1, {
map: 'Kino Der Toten',
policy,
observed: 100,
active: 10,
idle: 90,
availabilityObserved: 100,
noEmpty: 4.1,
noEmptyAtMaximum: 4.1
}),
...Array.from({ length: 5 }, (_, i) => map(i + 2, { policy, noEmpty: 0 }))
];
let body = render(MetricsExplore, { props: { report: data } }).body;
expect(body.match(/Autoscaling is globally paused\./g)).toHaveLength(1);
expect(body).toContain('Consider raising the policy maximum');
expect(body).toContain('4.1%');
expect(body).toContain('href="/autoscaling#t5-all"');
expect(body).not.toContain('Review and resume the autoscaling policy');
const highlighted = body.slice(0, body.indexOf('Other findings'));
expect(highlighted.match(/class="recommendation /g)).toHaveLength(1);
expect(highlighted).toContain('Kino Der Toten');
data.autoscalingPaused = false;
body = render(MetricsExplore, { props: { report: data } }).body;
expect(body).not.toContain('Autoscaling is globally paused.');
expect(body).toContain('Consider raising the policy maximum');
});
it('keeps accessible chart selection, partial periods and gaps while collapsing inspection controls', () => { it('keeps accessible chart selection, partial periods and gaps while collapsing inspection controls', () => {
const data = report(); const data = report();
data.points.push({ data.points.push({
@@ -296,6 +336,30 @@ it('keeps average lobby bar heights comparable across partial periods and collec
).toBe(true); ).toBe(true);
}); });
it('renders whole-lobby axis ticks without rounding fractional averages or other measures', () => {
const data = report();
data.points[0] = { ...data.points[0], active: 1.5, idle: 1, observed: 2.5, unknown: 1.25 };
const renderChart = () =>
render(MetricsChart, {
props: { points: data.points, days: data.days, timeZone: data.scope.timeZone }
}).body;
const axisLabels = (body: string) => {
const axis = body.match(/<div class="y-axis[\s\S]*?<\/div>/)?.[0] ?? '';
return [...axis.matchAll(/<span[^>]*>([^<]+)<\/span>/g)].map((match) => match[1]);
};
let body = renderChart();
expect(axisLabels(body)).toEqual(['4', '3', '2', '1', '0']);
expect(body).toContain('1.5 with players');
scope('?chartMode=percent');
body = renderChart();
expect(axisLabels(body)).toEqual(['100%', '75%', '50%', '25%', '0%']);
scope('?chartMode=hours');
body = renderChart();
expect(axisLabels(body)).toEqual(['4', '3', '2', '1', '0']);
data.points[0] = { ...data.points[0], active: 0.5, idle: 0, observed: 0.5, unknown: 0 };
expect(axisLabels(renderChart())).toContain('0.375');
});
it('uses shallow URL state for the selected breakdown while retaining the common summary', () => { it('uses shallow URL state for the selected breakdown while retaining the common summary', () => {
scope('?view=maps', '?view=instances&serverQ=theater&serverPage=2'); scope('?view=maps', '?view=instances&serverQ=theater&serverPage=2');
let body = render(MetricsPage, { let body = render(MetricsPage, {
+19
View File
@@ -3,6 +3,7 @@ import {
compareMetricValues, compareMetricValues,
metricAverageLobbies, metricAverageLobbies,
metricDuration, metricDuration,
metricLobbyAxis,
metricNumber, metricNumber,
metricPercent, metricPercent,
metricPeriodLabels metricPeriodLabels
@@ -17,6 +18,24 @@ describe('metric presentation', () => {
expect(metricAverageLobbies(0, 0)).toBeNull(); expect(metricAverageLobbies(0, 0)).toBeNull();
expect(metricAverageLobbies(4, Number.NaN)).toBeNull(); expect(metricAverageLobbies(4, Number.NaN)).toBeNull();
}); });
it.each<[number, number[]]>([
[0, [1, 0]],
[0.4, [1, 0]],
[1, [1, 0]],
[2.1, [3, 2, 1, 0]],
[3.75, [4, 3, 2, 1, 0]],
[5, [6, 4, 2, 0]],
[14, [15, 10, 5, 0]],
[41.2, [60, 40, 20, 0]],
[999, [1000, 500, 0]],
[2500, [3000, 2000, 1000, 0]]
])('uses whole-lobby ticks that cover an average of %s', (maximum, expected) => {
const axis = metricLobbyAxis(maximum);
expect(axis.ticks).toEqual(expected);
expect(axis.ceiling).toBe(axis.ticks[0]);
expect(axis.ceiling).toBeGreaterThanOrEqual(maximum);
expect(axis.ticks.every(Number.isInteger)).toBe(true);
});
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%');