diff --git a/src/lib/ui/metrics-refresh.ts b/src/lib/ui/metrics-refresh.ts
index 63ce9b8..89d197a 100644
--- a/src/lib/ui/metrics-refresh.ts
+++ b/src/lib/ui/metrics-refresh.ts
@@ -55,6 +55,7 @@ function validReport(value: unknown): value is MetricsData {
return false;
if (
!numeric(value.registeredServerCount) ||
+ typeof value.autoscalingPaused !== 'boolean' ||
![
'firstRetainedSampleAt',
'latestSampleAt',
diff --git a/src/lib/ui/metrics.ts b/src/lib/ui/metrics.ts
index c48bf50..0ee6da3 100644
--- a/src/lib/ui/metrics.ts
+++ b/src/lib/ui/metrics.ts
@@ -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;
diff --git a/src/routes/metrics/+page.svelte b/src/routes/metrics/+page.svelte
index 1a06aa8..8e417b6 100644
--- a/src/routes/metrics/+page.svelte
+++ b/src/routes/metrics/+page.svelte
@@ -336,7 +336,7 @@
{#if captured > 0}
{metricDuration(report.summary.observed)} known server time{metricDuration(report.summary.observed)} known lobby time
{metricDuration(report.summary.unknown)} unknown status{/if}
diff --git a/tests/unit/metrics-refresh.test.ts b/tests/unit/metrics-refresh.test.ts
index 572e811..3cc1182 100644
--- a/tests/unit/metrics-refresh.test.ts
+++ b/tests/unit/metrics-refresh.test.ts
@@ -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' } }] },
diff --git a/tests/unit/metrics.test.ts b/tests/unit/metrics.test.ts
index 6cb0df3..7cff950 100644
--- a/tests/unit/metrics.test.ts
+++ b/tests/unit/metrics.test.ts
@@ -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 () => {
diff --git a/tests/unit/report-workers.test.ts b/tests/unit/report-workers.test.ts
index 78def73..9ca925f 100644
--- a/tests/unit/report-workers.test.ts
+++ b/tests/unit/report-workers.test.ts
@@ -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();
});
diff --git a/tests/unit/ui-metrics-page.test.ts b/tests/unit/ui-metrics-page.test.ts
index b8cac4c..803fde0 100644
--- a/tests/unit/ui-metrics-page.test.ts
+++ b/tests/unit/ui-metrics-page.test.ts
@@ -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(/