From 2b155ded8f10066fe1112a5e34e1aa98b0793c6a Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Thu, 8 Oct 2026 16:16:56 -0400 Subject: [PATCH] Clarify provisioning source labels and filters --- src/lib/provisioning-list.ts | 26 ++++++++- src/lib/server/provisioning/history.ts | 13 ++--- src/routes/provisioning/+page.svelte | 17 ++---- tests/unit/provisioning-list.test.ts | 76 ++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 21 deletions(-) diff --git a/src/lib/provisioning-list.ts b/src/lib/provisioning-list.ts index 024c2cf..0f45333 100644 --- a/src/lib/provisioning-list.ts +++ b/src/lib/provisioning-list.ts @@ -3,7 +3,29 @@ import type { ProvisioningDeployment } from './provisioning-detail.js'; import type { ProvisioningSource } from './provisioning-detail.js'; export const provisioningListStatuses = ['pending', 'running', 'awaiting-health', 'done', 'attention', 'failed', 'interrupted', 'warning', 'cancelled'] as const; -export const provisioningListSources = ['manual', 'campaign', 'beta', 'maintenance', 'rebalance', 'system'] as const; +export const provisioningListSources = ['manual', 'campaign', 'beta', 'maintenance', 'recovery', 'autoscaling', 'rebalance', 'system'] as const; +export type ProvisioningListSource = typeof provisioningListSources[number]; +export const provisioningSourceLabels: Record = { + manual: 'Manual', campaign: 'Campaign', beta: 'Beta', maintenance: 'Maintenance', + recovery: 'Recovery', autoscaling: 'Autoscaling', rebalance: 'Rebalance', system: 'System' +}; +export const provisioningSourceKinds = { + campaign: ['campaign'], beta: ['beta'], maintenance: ['maintenance'], + recovery: ['watchdog'], autoscaling: ['autoscaling'], rebalance: ['rebalance'] +} as const satisfies Record, readonly ProvisioningSource['kind'][]>; + +/** Record titles and incident summaries belong in expanded details, not the source column. */ +export function provisioningSourceLabel(row: Pick): string { + const kind = row.sources?.[0]?.kind; + return provisioningSourceLabels[kind === 'watchdog' ? 'recovery' : kind ?? (row.triggered_by === 'ui' ? 'manual' : 'system')]; +} + +export function provisioningMatchesSource(row: Pick, source: ProvisioningListSource): boolean { + const sources = row.sources ?? []; + if (source === 'manual' || source === 'system') return !sources.length && (source === 'manual' ? row.triggered_by === 'ui' : row.triggered_by !== 'ui'); + const kinds: readonly ProvisioningSource['kind'][] = provisioningSourceKinds[source]; + return sources.some(item => kinds.includes(item.kind)); +} export type ProvisioningListRow = Pick `(EXISTS (SELECT 1 FROM provisioning_sources src WHERE src.operation_id=d.id AND src.kind IN (${kinds.map(kind => `'${kind}'`).join(',')})) + const linked = (kinds: readonly string[]) => `(EXISTS (SELECT 1 FROM provisioning_sources src WHERE src.operation_id=d.id AND src.kind IN (${kinds.map(kind => `'${kind}'`).join(',')})) OR ${kinds.map(kind => `d.triggered_by LIKE '${kind}:%'`).join(' OR ')})`; - if (options.source === 'campaign' || options.source === 'beta') conditions.push(linked([options.source])); - else if (options.source === 'maintenance') conditions.push(linked(['maintenance', 'watchdog'])); - else if (options.source === 'rebalance') conditions.push("d.triggered_by LIKE 'rebalance:%'"); - else if (options.source === 'manual' || options.source === 'system') { - conditions.push(`NOT ${linked(['campaign', 'beta', 'maintenance', 'watchdog', 'rebalance'])}`); + if (options.source === 'manual' || options.source === 'system') { + conditions.push(`NOT ${linked(Object.values(provisioningSourceKinds).flat())}`); conditions.push(options.source === 'manual' ? "d.triggered_by='ui'" : "d.triggered_by!='ui'"); - } + } else if (options.source) conditions.push(linked(provisioningSourceKinds[options.source])); const search = (options.server ?? '').slice(0, 100).toLowerCase(); if (search) { // Read only distinct names to preserve friendly map/lobby searches, including deleted servers. diff --git a/src/routes/provisioning/+page.svelte b/src/routes/provisioning/+page.svelte index 5b7f172..52961c8 100644 --- a/src/routes/provisioning/+page.svelte +++ b/src/routes/provisioning/+page.svelte @@ -16,7 +16,7 @@ import RelativeTime from '$ui/components/RelativeTime.svelte'; import LifecycleDialog from '$ui/components/LifecycleDialog.svelte'; - import { provisioningListStatuses, provisioningListSources, provisioningPageSize, provisioningPageOffset, type ProvisioningListRow as Deployment, type ProvisioningListPage } from '$lib/provisioning-list.js'; + import { provisioningListStatuses, provisioningListSources, provisioningSourceLabels, provisioningSourceLabel, provisioningMatchesSource, provisioningPageSize, provisioningPageOffset, type ProvisioningListSource, type ProvisioningListRow as Deployment, type ProvisioningListPage } from '$lib/provisioning-list.js'; import { provisioningActions, actionLabels } from '$lib/provisioning.js'; import { contextHref } from '$lib/navigation-context.js'; @@ -40,7 +40,7 @@ let limit = $state(25); let offset = $state(0); let historyState = $state('all'); - let source = $state('all'); + let source = $state('all'); let expanded = $state>({}); let fleetActionsOpen = $state(false); let now = $state(Date.now()); @@ -55,7 +55,7 @@ tab = ['active', 'history'].includes(params.get('view') ?? '') ? params.get('view')! : 'active'; action = provisioningActions.some(value => value === params.get('action')) ? params.get('action')! : 'all'; historyState = provisioningListStatuses.some(value => value === params.get('status')) ? params.get('status')! : 'all'; - source = provisioningListSources.some(value => value === params.get('source')) ? params.get('source')! : 'all'; + source = provisioningListSources.find(value => value === params.get('source')) ?? 'all'; search = params.get('server') ?? ''; limit = provisioningPageSize(Number(params.get('limit') ?? 25)); offset = provisioningPageOffset(Number(params.get('offset') ?? 0)); @@ -141,12 +141,7 @@ if (action !== 'all' && row.action !== action) return false; if (historyState !== 'all' && (historyState === 'pending' ? !['queued','waiting'].includes(row.state) : row.state !== historyState)) return false; if (search && !`${row.server} ${serverTitle(row.server)}`.toLowerCase().includes(search.toLowerCase())) return false; - if (source !== 'all') { - const sources = row.sources ?? []; - if (source === 'manual' && (sources.length || row.triggered_by !== 'ui')) return false; - if (source === 'system' && (sources.length || row.triggered_by === 'ui')) return false; - if (['campaign','beta','maintenance','rebalance'].includes(source) && !sources.some(item => source === 'maintenance' ? ['maintenance','watchdog'].includes(item.kind) : item.kind === source)) return false; - } + if (source !== 'all' && !provisioningMatchesSource(row, source)) return false; return true; })); const queueSummary = $derived.by(() => { @@ -175,7 +170,7 @@ - + {#if filtered}{/if} @@ -225,7 +220,7 @@ {actionLabels[row.action]} {status.label}{#if ['queued','waiting'].includes(status.group) && row.phaseStartedAt && now - Date.parse(row.phaseStartedAt) >= 600000}In this stage {elapsedTime(row.phaseStartedAt, now)}{/if} - {#if primarySource?.href}{primarySource.label}{:else}{primarySource?.label ?? (row.triggered_by === 'ui' ? 'Manual' : 'System')}{/if}{#if (row.sources?.length ?? 0) > 1}+{row.sources!.length - 1} more{/if} + {#if primarySource?.href}{provisioningSourceLabel(row)}{:else}{provisioningSourceLabel(row)}{/if}{#if (row.sources?.length ?? 0) > 1}+{row.sources!.length - 1} more{/if} diff --git a/tests/unit/provisioning-list.test.ts b/tests/unit/provisioning-list.test.ts index 583785c..826e9de 100644 --- a/tests/unit/provisioning-list.test.ts +++ b/tests/unit/provisioning-list.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { newFixture, type Fixture } from '../helpers/fixture.js'; +import { provisioningListSources, provisioningMatchesSource, provisioningSourceLabel, type ProvisioningListSource } from '../../src/lib/provisioning-list.js'; let fixture: Fixture; beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); }); @@ -19,6 +20,79 @@ async function setup() { } describe('bounded provisioning lists', () => { + it('keeps recovery sources concise as incident summaries change, including after retention', async () => { + const { add, handle, deploymentListPage } = await setup(); + add('recovery', 'waiting', 'rebuild'); + handle.prepare("INSERT INTO provisioning_sources VALUES ('recovery','watchdog','incident','t5_zombie_theater_1')").run(); + handle.prepare(`INSERT INTO watchdog_incidents (id,server,kind,state,summary,first_seen_at,last_seen_at) + VALUES ('incident','t5_zombie_theater_1','health','recovering',?,'2026-01-01','2026-01-01')`) + .run('Rebuild queued with the latest promoted setup'); + + for (const summary of ['Rebuild queued with the latest promoted setup', 'Recovered game service']) { + handle.prepare('UPDATE watchdog_incidents SET summary=? WHERE id=?').run(summary, 'incident'); + const row = deploymentListPage({ view: 'active' }).deployments[0]; + expect(provisioningSourceLabel(row)).toBe('Recovery'); + expect(row.sources).toEqual([{ kind: 'watchdog', source_id: 'incident', label: summary, href: '/maintenance/incident' }]); + } + handle.prepare("DELETE FROM watchdog_incidents WHERE id='incident'").run(); + const retained = deploymentListPage({ view: 'active' }).deployments[0]; + expect(provisioningSourceLabel(retained)).toBe('Recovery'); + expect(retained.sources?.[0].href).toBeNull(); + }); + + it.each(['active', 'history'] as const)('keeps %s source categories and client filters aligned with API filters', async view => { + const { add, handle, deploymentListPage } = await setup(); + const categories: [ProvisioningListSource, string, string][] = [ + ['campaign', 'campaign', 'Campaign'], ['beta', 'beta', 'Beta'], ['maintenance', 'maintenance', 'Maintenance'], + ['recovery', 'watchdog', 'Recovery'], ['autoscaling', 'autoscaling', 'Autoscaling'], ['rebalance', 'rebalance', 'Rebalance'] + ]; + const state = view === 'active' ? 'waiting' : 'done'; + for (const [source, kind] of categories) { + // Rebalance ownership is recorded only in triggered_by. + if (source !== 'rebalance') { + add(`${source}-linked`, state); + handle.prepare('INSERT INTO provisioning_sources VALUES (?,?,?,?)').run(`${source}-linked`, kind, 'expired', 't5_zombie_theater_1'); + } + add(`${source}-legacy`, state); + handle.prepare('UPDATE server_deployments SET triggered_by=? WHERE id=?').run(`${kind}:expired`, `${source}-legacy`); + } + add('manual', state); + handle.prepare("UPDATE server_deployments SET triggered_by='ui' WHERE id='manual'").run(); + add('system', state); + // A shared operation must be discoverable through each of its sources, even when initiated manually. + add('shared', state); + handle.prepare("UPDATE server_deployments SET triggered_by='ui' WHERE id='shared'").run(); + handle.exec(`INSERT INTO provisioning_sources VALUES ('shared','campaign','expired','t5_zombie_theater_1'); + INSERT INTO provisioning_sources VALUES ('shared','watchdog','expired','t5_zombie_theater_1');`); + + const rows = deploymentListPage({ view }).deployments; + expect(rows).toHaveLength(14); + for (const [source, , label] of categories) { + for (const suffix of source === 'rebalance' ? ['legacy'] : ['linked', 'legacy']) { + expect(provisioningSourceLabel(rows.find(row => row.id === `${source}-${suffix}`)!)).toBe(label); + } + } + expect(provisioningSourceLabel(rows.find(row => row.id === 'manual')!)).toBe('Manual'); + expect(provisioningSourceLabel(rows.find(row => row.id === 'system')!)).toBe('System'); + + const { GET } = await import('../../src/routes/api/provisioning/+server.js'); + for (const source of provisioningListSources) { + const expected = source === 'manual' || source === 'system' ? [source] + : source === 'rebalance' ? ['rebalance-legacy'] + : [`${source}-linked`, `${source}-legacy`, ...(['campaign', 'recovery'].includes(source) ? ['shared'] : [])]; + expect(rows.filter(row => provisioningMatchesSource(row, source)).map(row => row.id).sort()).toEqual(expected.sort()); + const result = await GET({ url: new URL(`http://localhost/api/provisioning?view=${view}&source=${source}`) }).json(); + expect(result.total).toBe(expected.length); + expect(result.deployments.map((row: { id: string }) => row.id).sort()).toEqual(expected); + if (view === 'history') { + const page = deploymentListPage({ view, source, limit: 1, offset: expected.length - 1 }); + expect(page.total).toBe(expected.length); + expect(page.deployments).toHaveLength(1); + expect(provisioningMatchesSource(page.deployments[0], source)).toBe(true); + } + } + }); + it('filters readable sources before pagination and retains expired source references without broken links', async () => { const { add, handle, deploymentListPage } = await setup(); handle.exec(`INSERT INTO scripts(id,name,target_rel_path) VALUES(1,'Common scripts','common.gsc'); @@ -41,6 +115,8 @@ describe('bounded provisioning lists', () => { expect(second.deployments[0]).toMatchObject({ id: 'old-campaign', sources: [{ label: 'Promotion · Beta #75', href: '/campaigns/campaign' }] }); const beta = deploymentListPage({ view: 'active', source: 'beta', status: 'pending' }); expect(beta.deployments).toEqual([expect.objectContaining({ id: 'linked-beta', sources: [{ kind: 'beta', source_id: '75', label: 'Beta #75: Gameplay checks', href: '/betas/75' }] })]); + expect(provisioningSourceLabel(second.deployments[0])).toBe('Campaign'); + expect(provisioningSourceLabel(beta.deployments[0])).toBe('Beta'); expect(deploymentListPage({ view: 'history', source: 'manual' }).deployments.map(row => row.id)).toEqual(['manual']); expect(deploymentListPage({ view: 'history', source: 'system' }).total).toBe(30); const { GET } = await import('../../src/routes/api/provisioning/+server.js');