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
+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%');