Clarify provisioning source labels and filters
This commit is contained in:
@@ -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<ProvisioningListSource, string> = {
|
||||
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<Exclude<ProvisioningListSource, 'manual' | 'system'>, readonly ProvisioningSource['kind'][]>;
|
||||
|
||||
/** Record titles and incident summaries belong in expanded details, not the source column. */
|
||||
export function provisioningSourceLabel(row: Pick<ProvisioningListRow, 'sources' | 'triggered_by'>): string {
|
||||
const kind = row.sources?.[0]?.kind;
|
||||
return provisioningSourceLabels[kind === 'watchdog' ? 'recovery' : kind ?? (row.triggered_by === 'ui' ? 'manual' : 'system')];
|
||||
}
|
||||
|
||||
export function provisioningMatchesSource(row: Pick<ProvisioningListRow, 'sources' | 'triggered_by'>, 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<ProvisioningDeployment,
|
||||
'id' | 'server' | 'server_deleted_at' | 'action' | 'state' | 'stage' | 'started_at' |
|
||||
@@ -16,7 +38,7 @@ export interface ProvisioningListOptions {
|
||||
view?: 'active' | 'history' | 'all';
|
||||
action?: ProvisioningAction;
|
||||
status?: typeof provisioningListStatuses[number];
|
||||
source?: typeof provisioningListSources[number];
|
||||
source?: ProvisioningListSource;
|
||||
server?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
|
||||
@@ -18,7 +18,7 @@ import { ensureHostSchema } from '../hosts/schema.js';
|
||||
import { safetyOverride, safetyOverrideAvailability } from './safety-override.js';
|
||||
import { serverTitle } from '../../ui/server-labels.js';
|
||||
import { provisioningQueueStatus } from '../../ui/provisioning-queue.js';
|
||||
import { provisioningPageSize, provisioningPageOffset, type ProvisioningListOptions, type ProvisioningListPage, type ProvisioningListRow } from '../../provisioning-list.js';
|
||||
import { provisioningPageSize, provisioningPageOffset, provisioningSourceKinds, type ProvisioningListOptions, type ProvisioningListPage, type ProvisioningListRow } from '../../provisioning-list.js';
|
||||
|
||||
export function beginDeployment(server: string, action: ProvisioningAction, triggeredBy = 'ui'): string {
|
||||
const id = randomUUID();
|
||||
@@ -320,15 +320,12 @@ function deploymentListQuery(options: ProvisioningListOptions) {
|
||||
if (options.status === 'attention') conditions.push(reviewDeployment);
|
||||
else if (options.status === 'pending') conditions.push("d.state IN ('queued','waiting')");
|
||||
else if (options.status) { conditions.push('d.state=?'); parameters.push(options.status); }
|
||||
const linked = (kinds: string[]) => `(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.
|
||||
|
||||
@@ -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<ProvisioningListSource | 'all'>('all');
|
||||
let expanded = $state<Record<string, boolean>>({});
|
||||
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 @@
|
||||
<label class="search">Server<input type="search" bind:value={search} oninput={() => saveContext(true)} placeholder="Search server or lobby" maxlength="100" /></label>
|
||||
<label>Operation<select bind:value={action} onchange={() => saveContext(true)}><option value="all">All operations</option>{#each provisioningActions as value}<option value={value}>{actionLabels[value]}</option>{/each}</select></label>
|
||||
<label>Status<select bind:value={historyState} onchange={() => saveContext(true)}><option value="all">All statuses</option><option value="pending">Waiting / queued</option><option value="running">In progress</option><option value="awaiting-health">Verifying health</option><option value="done">Complete</option><option value="attention">Needs attention</option><option value="failed">Failed</option><option value="interrupted">Interrupted</option><option value="warning">Warnings</option><option value="cancelled">Cancelled</option></select></label>
|
||||
<label>Source<select bind:value={source} onchange={() => saveContext(true)}><option value="all">All sources</option><option value="manual">Manual</option><option value="campaign">Campaign</option><option value="beta">Beta</option><option value="maintenance">Maintenance</option><option value="rebalance">Rebalance</option><option value="system">System</option></select></label>
|
||||
<label>Source<select bind:value={source} onchange={() => saveContext(true)}><option value="all">All sources</option>{#each provisioningListSources as value}<option value={value}>{provisioningSourceLabels[value]}</option>{/each}</select></label>
|
||||
{#if filtered}<Button variant="ghost" onclick={clearFilters}>Clear filters</Button>{/if}
|
||||
</div>
|
||||
<LifecycleDialog bind:open={lifecycleOpen} />
|
||||
@@ -225,7 +220,7 @@
|
||||
<td class="server-cell"><HistoricalServerIdentity name={row.server} deletedAt={row.server_deleted_at} href={row.server_exists ? contextHref(`/servers/${encodeURIComponent(row.server)}`, $page.url) : undefined} /></td>
|
||||
<td><a href={operationHref}>{actionLabels[row.action]}</a></td>
|
||||
<td><Badge variant={status.tone} size="md">{status.label}</Badge>{#if ['queued','waiting'].includes(status.group) && row.phaseStartedAt && now - Date.parse(row.phaseStartedAt) >= 600000}<small>In this stage {elapsedTime(row.phaseStartedAt, now)}</small>{/if}</td>
|
||||
<td class="source-cell">{#if primarySource?.href}<a href={contextHref(primarySource.href, $page.url)}>{primarySource.label}</a>{:else}<span>{primarySource?.label ?? (row.triggered_by === 'ui' ? 'Manual' : 'System')}</span>{/if}{#if (row.sources?.length ?? 0) > 1}<small>+{row.sources!.length - 1} more</small>{/if}</td>
|
||||
<td class="source-cell">{#if primarySource?.href}<a href={contextHref(primarySource.href, $page.url)}>{provisioningSourceLabel(row)}</a>{:else}<span>{provisioningSourceLabel(row)}</span>{/if}{#if (row.sources?.length ?? 0) > 1}<small>+{row.sources!.length - 1} more</small>{/if}</td>
|
||||
<td><RelativeTime value={row.started_at} /></td>
|
||||
<td class="align-right"><button class="text-button" aria-expanded={isExpanded} aria-controls={`operation-${row.id}`} aria-label={`${isExpanded ? 'Hide' : 'Show'} ${actionLabels[row.action].toLowerCase()} details for ${serverTitle(row.server)}`} onclick={() => expanded = { ...expanded, [row.id]: !isExpanded }}>{isExpanded ? 'Hide' : 'Details'} <span aria-hidden="true">{isExpanded ? '−' : '+'}</span></button></td>
|
||||
</tr>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user