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**,
**Empty spare**, and **Unknown**. Each bucket divides those lobby-hours by its
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.
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
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 |
| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| 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
`availabilityUnknown` durations, plus current `policy` context and
`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.
+2
View File
@@ -130,6 +130,8 @@ export interface MetricsData {
firstRetainedSampleAt: number | null;
latestSampleAt: number | null;
registeredServerCount: number;
/** Current fleet-wide pause, separate from each policy's own state. */
autoscalingPaused: boolean;
scope: MetricsScope;
scopeOptions: { games: string[]; maps: { game: string; map: string }[] };
scopeFirstSampleAt: number | null;
+2 -5
View File
@@ -123,11 +123,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
minimum,
maximum,
spares,
pausedReason: globalPaused
? ['Autoscaling is globally paused.', row.paused_reason]
.filter(Boolean)
.join(' ')
: row.paused_reason
pausedReason: row.paused_reason
}
};
});
@@ -441,6 +437,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
since,
now,
...metadata,
autoscalingPaused: globalPaused,
scope,
scopeOptions,
summary,
@@ -6,6 +6,7 @@
import {
metricDuration,
metricAverageLobbies,
metricLobbyAxis,
metricNumber,
metricPercent,
metricPeriodLabels
@@ -34,8 +35,8 @@
});
const selected = $derived(points[selectedIndex]);
const selectedValue = $derived(selected ? String(selected.at) : '');
const ceiling = $derived.by(() => {
if (mode === 'percent') return 100;
const axis = $derived.by(() => {
if (mode === 'percent') return { ceiling: 100, ticks: [100, 75, 50, 25, 0] };
const max = Math.max(
0,
...points.map((point) =>
@@ -45,15 +46,20 @@
: point.observed + point.unknown
)
);
if (!max) return 1;
const magnitude = 10 ** Math.floor(Math.log10(max));
return Math.ceil(max / (magnitude / 2)) * (magnitude / 2);
if (mode === 'average') return metricLobbyAxis(max);
const magnitude = max ? 10 ** Math.floor(Math.log10(max)) : 1;
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 ticks = $derived(
[1, 0.75, 0.5, 0.25, 0].map((fraction) => ({
fraction,
value: `${tick.format(ceiling * fraction)}${mode === 'percent' ? '%' : ''}`
axis.ticks.map((value) => ({
fraction: value / ceiling,
value: `${tick.format(value)}${mode === 'percent' ? '%' : ''}`
}))
);
const periodOptions = $derived(
@@ -70,6 +70,10 @@
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 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}
{:else}<p class="empty">No policy changes meet the evidence threshold in this period.</p>{/if}
{#if limited}<p class="notice">
@@ -124,8 +128,9 @@
empty. Player limits are not needed for these signals.
</li>
<li>
Maps without a current policy suggest adding one. Paused or disabled policies prompt
a review before resuming or enabling them.
Maps without a current policy suggest adding one. Individually paused or disabled
policies prompt a review before resuming or enabling them. A global pause does not
replace shortage signals.
</li>
<li>
All-unknown occupancy prompts a reporting check. Other cases suggest watching
@@ -54,7 +54,7 @@
);
}
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) {
selectedKey = day * 24 + hour;
@@ -102,7 +102,7 @@
<div>
<h2 id={`${id}-heatmap-title`}>Activity by weekday and hour</h2>
<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>
</div>
<div>
<dt>Unknown server time</dt>
<dt>Unknown lobby time</dt>
<dd>{metricDuration(selected.unknown)}</dd>
</div>
<div>
+1
View File
@@ -55,6 +55,7 @@ function validReport(value: unknown): value is MetricsData {
return false;
if (
!numeric(value.registeredServerCount) ||
typeof value.autoscalingPaused !== 'boolean' ||
![
'firstRetainedSampleAt',
'latestSampleAt',
+16
View File
@@ -38,6 +38,22 @@ export function metricAverageLobbies(lobbyHours: number, capturedHours: number):
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 {
if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return '—';
const percent = (100 * value) / total;
+1 -1
View File
@@ -336,7 +336,7 @@
<summary>About this report</summary>
<div class="context">
{#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
><strong>{metricDuration(report.summary.unknown)}</strong> unknown status</span
>{/if}
+2
View File
@@ -15,6 +15,7 @@ const report = (days: number) => ({
scope: { game: null, map: null, timeZone: 'UTC' },
scopeOptions: { games: [], maps: [] },
registeredServerCount: 0,
autoscalingPaused: false,
firstRetainedSampleAt: null,
latestSampleAt: null,
scopeFirstSampleAt: null,
@@ -168,6 +169,7 @@ describe('metrics background refresh', () => {
]
};
const invalid = [
{ ...value, autoscalingPaused: undefined },
{ ...value, running: undefined },
{ ...value, previous: { ...value.previous, running: undefined } },
{ ...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')!
.policy
).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();
const globallyPaused = readMetrics(1, at + 30_000);
expect(globallyPaused.autoscalingPaused).toBe(true);
expect(globallyPaused.maps).toEqual(beforeGlobalPause.maps);
expect(
readMetrics(1, at + 30_000).maps.find((map) => map.game === 't5' && map.map === 'Moon')!
.policy?.pausedReason
).toBe('Autoscaling is globally paused. Operator paused.');
globallyPaused.maps.find((map) => map.game === 't5' && map.map === 'Moon')!.policy
?.pausedReason
).toBe('Operator paused.');
});
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'"
)
.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();
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();
expect((await metricsReport(query)).maps[0].policy).toBeNull();
});
+64
View File
@@ -51,6 +51,7 @@ function report(): MetricsData {
firstRetainedSampleAt: now - 14 * 86400000,
latestSampleAt: now,
registeredServerCount: 24,
autoscalingPaused: false,
scope: { game: null, map: null, timeZone: 'America/New_York' },
scopeOptions: { games: ['t5'], maps: [] },
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', () => {
const data = report();
data.points.push({
@@ -296,6 +336,30 @@ it('keeps average lobby bar heights comparable across partial periods and collec
).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', () => {
scope('?view=maps', '?view=instances&serverQ=theater&serverPage=2');
let body = render(MetricsPage, {
+19
View File
@@ -3,6 +3,7 @@ import {
compareMetricValues,
metricAverageLobbies,
metricDuration,
metricLobbyAxis,
metricNumber,
metricPercent,
metricPeriodLabels
@@ -17,6 +18,24 @@ describe('metric presentation', () => {
expect(metricAverageLobbies(0, 0)).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', () => {
expect(metricPercent(0, 0)).toBe('—');
expect(metricPercent(0, 1)).toBe('0.0%');