From ea932ec563ef4b95c4ea4e703bf0a6c2bc4f317a Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 10 Oct 2026 16:11:24 -0400 Subject: [PATCH] Preserve shortage signals during global pause (Refs #137) --- docs/guides/metrics.md | 11 +++- src/lib/metrics.ts | 2 + src/lib/server/fleet/metrics.ts | 7 +- .../ui/components/metrics/MetricsChart.svelte | 22 ++++--- .../components/metrics/MetricsExplore.svelte | 9 ++- .../components/metrics/MetricsHeatmap.svelte | 6 +- src/lib/ui/metrics-refresh.ts | 1 + src/lib/ui/metrics.ts | 16 +++++ src/routes/metrics/+page.svelte | 2 +- tests/unit/metrics-refresh.test.ts | 2 + tests/unit/metrics.test.ts | 11 +++- tests/unit/report-workers.test.ts | 8 ++- tests/unit/ui-metrics-page.test.ts | 64 +++++++++++++++++++ tests/unit/ui-metrics.test.ts | 19 ++++++ 14 files changed, 154 insertions(+), 26 deletions(-) diff --git a/docs/guides/metrics.md b/docs/guides/metrics.md index ecf33ec..0698dc2 100644 --- a/docs/guides/metrics.md +++ b/docs/guides/metrics.md @@ -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. diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index 733427d..d7f7301 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -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; diff --git a/src/lib/server/fleet/metrics.ts b/src/lib/server/fleet/metrics.ts index 5fde9b8..7ec1f22 100644 --- a/src/lib/server/fleet/metrics.ts +++ b/src/lib/server/fleet/metrics.ts @@ -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, diff --git a/src/lib/ui/components/metrics/MetricsChart.svelte b/src/lib/ui/components/metrics/MetricsChart.svelte index ad2b9f4..695f3ac 100644 --- a/src/lib/ui/components/metrics/MetricsChart.svelte +++ b/src/lib/ui/components/metrics/MetricsChart.svelte @@ -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( diff --git a/src/lib/ui/components/metrics/MetricsExplore.svelte b/src/lib/ui/components/metrics/MetricsExplore.svelte index 44254e9..e2c42d1 100644 --- a/src/lib/ui/components/metrics/MetricsExplore.svelte +++ b/src/lib/ui/components/metrics/MetricsExplore.svelte @@ -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.

+ {#if report.autoscalingPaused}

+ Autoscaling is globally paused. Review the shortage signals and current settings before + resuming in Autoscaling. +

{/if} {#if highlighted.length}{#each highlighted as suggestion}{@render finding(suggestion)}{/each} {:else}

No policy changes meet the evidence threshold in this period.

{/if} {#if limited}

@@ -124,8 +128,9 @@ empty. Player limits are not needed for these signals.

  • - 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.
  • All-unknown occupancy prompts a reporting check. Other cases suggest watching diff --git a/src/lib/ui/components/metrics/MetricsHeatmap.svelte b/src/lib/ui/components/metrics/MetricsHeatmap.svelte index bf2491e..34352d4 100644 --- a/src/lib/ui/components/metrics/MetricsHeatmap.svelte +++ b/src/lib/ui/components/metrics/MetricsHeatmap.svelte @@ -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 @@

    Activity by weekday and hour

    - 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 @@

    -
    Unknown server time
    +
    Unknown lobby time
    {metricDuration(selected.unknown)}
    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 @@ About this report
    {#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(/