Sort active provisioning jobs by progress
This commit is contained in:
@@ -12,6 +12,13 @@ are shown. Confirmations name the server and its current players and round;
|
|||||||
disruptive actions still wait for a fresh empty-lobby check. **Manage server**
|
disruptive actions still wait for a fresh empty-lobby check. **Manage server**
|
||||||
on the operations page offers the same actions for a selected server.
|
on the operations page offers the same actions for a selected server.
|
||||||
|
|
||||||
|
The **Active** tab shows jobs verifying health first, then jobs executing work,
|
||||||
|
waiting jobs, and queued jobs. File preparation, cleanup, and submitted commands
|
||||||
|
awaiting host confirmation count as executing work. Within each group, older
|
||||||
|
requests appear first, with recording order breaking timestamp ties. **History**
|
||||||
|
shows the newest requests first. This display order does not change execution
|
||||||
|
priority or capacity admission.
|
||||||
|
|
||||||
**Create server** opens one dialog with game and map choices followed by automatic
|
**Create server** opens one dialog with game and map choices followed by automatic
|
||||||
readiness checks. Blockers explain what needs attention before launch. Runtime
|
readiness checks. Blockers explain what needs attention before launch. Runtime
|
||||||
and network overrides are under **Advanced settings**; image, configuration,
|
and network overrides are under **Advanced settings**; image, configuration,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { canCancelProvisioningJob, provisioningCancelled } from './cancellation.
|
|||||||
import { nativeDeployment } from '../backends/native.js';
|
import { nativeDeployment } from '../backends/native.js';
|
||||||
import { safetyOverride, safetyOverrideAvailability } from './safety-override.js';
|
import { safetyOverride, safetyOverrideAvailability } from './safety-override.js';
|
||||||
import { serverTitle } from '../../ui/server-labels.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, type ProvisioningListOptions, type ProvisioningListPage, type ProvisioningListRow } from '../../provisioning-list.js';
|
||||||
|
|
||||||
export function beginDeployment(server: string, action: ProvisioningAction, triggeredBy = 'ui'): string {
|
export function beginDeployment(server: string, action: ProvisioningAction, triggeredBy = 'ui'): string {
|
||||||
@@ -301,11 +302,28 @@ function deploymentListQuery(options: ProvisioningListOptions) {
|
|||||||
limit: provisioningPageSize(options.limit ?? 25), offset: provisioningPageOffset(options.offset ?? 0) };
|
limit: provisioningPageSize(options.limit ?? 25), offset: provisioningPageOffset(options.offset ?? 0) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function activeDeploymentRank(row: ProvisioningListRow): number {
|
||||||
|
const { group } = provisioningQueueStatus(row);
|
||||||
|
if (group === 'verifying') return 0;
|
||||||
|
if (group === 'running') return 1;
|
||||||
|
// Launch preparation keeps its queued/waiting state until command submission.
|
||||||
|
if (row.phase === 'prepare' && !row.waitCode) return 1;
|
||||||
|
// Remote file staging currently records its progress only in the stage message.
|
||||||
|
if (row.waitCode === 'native-host' && ['Staging and verifying files on the host.',
|
||||||
|
'Waiting for verified remote file staging.'].includes(row.stage)) return 1;
|
||||||
|
if (group === 'queued') return 3;
|
||||||
|
// A submitted command remains in progress while its agent result is pending.
|
||||||
|
if (row.state === 'running' && row.phase === 'container-action' && row.waitCode === 'native-host') return 1;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
function deploymentListRows(query: ReturnType<typeof deploymentListQuery>, active: boolean): ProvisioningListRow[] {
|
function deploymentListRows(query: ReturnType<typeof deploymentListQuery>, active: boolean): ProvisioningListRow[] {
|
||||||
const rows = db().prepare(`SELECT ${deploymentListColumns} FROM server_deployments d WHERE ${query.where}
|
const rows = db().prepare(`SELECT ${deploymentListColumns} FROM server_deployments d WHERE ${query.where}
|
||||||
ORDER BY d.started_at DESC, d.rowid DESC ${active ? '' : 'LIMIT ? OFFSET ?'}`)
|
ORDER BY ${active ? 'd.started_at ASC, d.rowid ASC' : 'd.started_at DESC, d.rowid DESC LIMIT ? OFFSET ?'}`)
|
||||||
.all(REMOVING_SERVER, ...query.parameters, ...(active ? [] : [query.limit, query.offset])) as ProvisioningListRow[];
|
.all(REMOVING_SERVER, ...query.parameters, ...(active ? [] : [query.limit, query.offset])) as ProvisioningListRow[];
|
||||||
if (!rows.length) return rows;
|
if (!rows.length) return rows;
|
||||||
|
// Stable sorting keeps request/insertion order within each progress group.
|
||||||
|
if (active) rows.sort((left, right) => activeDeploymentRank(left) - activeDeploymentRank(right));
|
||||||
const links = db().prepare(`SELECT operation_id,kind,source_id FROM provisioning_sources
|
const links = db().prepare(`SELECT operation_id,kind,source_id FROM provisioning_sources
|
||||||
WHERE operation_id IN (SELECT value FROM json_each(?)) ORDER BY kind,source_id`).all(JSON.stringify(rows.map(row => row.id))) as
|
WHERE operation_id IN (SELECT value FROM json_each(?)) ORDER BY kind,source_id`).all(JSON.stringify(rows.map(row => row.id))) as
|
||||||
(Omit<ProvisioningSource, 'label' | 'href'> & { operation_id: string })[];
|
(Omit<ProvisioningSource, 'label' | 'href'> & { operation_id: string })[];
|
||||||
|
|||||||
@@ -70,6 +70,69 @@ describe('bounded provisioning lists', () => {
|
|||||||
expect(JSON.stringify(deploymentListPage({ view: 'active' }))).not.toContain('private snapshot');
|
expect(JSON.stringify(deploymentListPage({ view: 'active' }))).not.toContain('private snapshot');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('puts verification and executing work ahead of preflight waits and capacity queues', async () => {
|
||||||
|
const { add, handle, listDeployments, deploymentListPage } = await setup();
|
||||||
|
const addProgress = (id: string, state: string, second: number, phase: string,
|
||||||
|
waitCode: string | null = null, stage = 'Recorded', action = 'rebuild') => {
|
||||||
|
add(id, state, action, 't5_zombie_theater_1', null, stage, `2026-01-01T00:00:${String(second).padStart(2, '0')}.000Z`);
|
||||||
|
handle.prepare('UPDATE server_deployments SET phase_key=?, observation_json=? WHERE id=?')
|
||||||
|
.run(phase, JSON.stringify({ blocker: waitCode ? { code: waitCode } : null }), id);
|
||||||
|
};
|
||||||
|
// Request order is deliberately different from progress order.
|
||||||
|
addProgress('queued', 'queued', 1, 'requested');
|
||||||
|
addProgress('cleanup', 'waiting', 12, 'cleanup', null, 'Removing server files and registry', 'destroy');
|
||||||
|
addProgress('verify-newer', 'awaiting-health', 21, 'verify-map');
|
||||||
|
addProgress('capacity', 'running', 2, 'startup-queue', 'startup-capacity');
|
||||||
|
addProgress('operation-conflict', 'running', 6, 'container-action', 'operation-conflict');
|
||||||
|
addProgress('agent-wait', 'running', 11, 'container-action', 'native-host', 'Agent operation pending');
|
||||||
|
addProgress('host-wait', 'waiting', 5, 'eligibility', 'native-host', 'Host is unavailable; waiting for reconnection.');
|
||||||
|
addProgress('staging', 'waiting', 7, 'eligibility', 'native-host', 'Staging and verifying files on the host.');
|
||||||
|
addProgress('staging-wait', 'waiting', 8, 'eligibility', 'native-host', 'Waiting for verified remote file staging.');
|
||||||
|
addProgress('legacy-capacity', 'waiting', 3, 'eligibility', 'native-host', 'Queued to rebuild; waiting for another server to recover');
|
||||||
|
addProgress('verify-older', 'awaiting-health', 20, 'verify-container', 'native-host', 'Waiting for container readiness: starting');
|
||||||
|
addProgress('preparing-queued', 'queued', 9, 'prepare');
|
||||||
|
addProgress('executing', 'running', 10, 'prepare');
|
||||||
|
addProgress('eligibility', 'running', 4, 'eligibility', 'eligibility');
|
||||||
|
addProgress('interrupted', 'interrupted', 30, 'container-action', 'native-host');
|
||||||
|
addProgress('failed', 'failed', 31, 'verify-map', 'native-host');
|
||||||
|
|
||||||
|
const expected = ['verify-older', 'verify-newer', 'staging', 'staging-wait', 'preparing-queued', 'executing', 'agent-wait', 'cleanup',
|
||||||
|
'eligibility', 'host-wait', 'operation-conflict', 'queued', 'capacity', 'legacy-capacity'];
|
||||||
|
expect(listDeployments({ view: 'active' }).map(row => row.id)).toEqual(expected);
|
||||||
|
const { GET } = await import('../../src/routes/api/provisioning/+server.js');
|
||||||
|
const active = await GET({ url: new URL('http://localhost/api/provisioning?view=active&limit=1') }).json();
|
||||||
|
expect(active.deployments.map((row: { id: string }) => row.id)).toEqual(expected);
|
||||||
|
expect(active).toMatchObject({ total: expected.length, needsReview: 2 });
|
||||||
|
|
||||||
|
// Progress priority applies only to Active; retained outcomes and mixed reads stay newest first.
|
||||||
|
expect(deploymentListPage({ view: 'history', limit: 1 }).deployments.map(row => row.id)).toEqual(['failed']);
|
||||||
|
expect(deploymentListPage({ view: 'history', limit: 1, offset: 1 }).deployments.map(row => row.id)).toEqual(['interrupted']);
|
||||||
|
expect(listDeployments({ view: 'all', limit: 3 }).map(row => row.id)).toEqual(['failed', 'interrupted', 'verify-newer']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps oldest requests and insertion ties stable while active progress is polled', async () => {
|
||||||
|
const { add, handle, listDeployments } = await setup();
|
||||||
|
add('z-first', 'awaiting-health', 'rebuild', 't5_zombie_theater_1', null, 'Checking game status', '2026-01-02');
|
||||||
|
add('a-second', 'awaiting-health', 'rebuild', 't5_zombie_theater_1', null, 'Checking selected engine map', '2026-01-02');
|
||||||
|
add('old-inserted-last', 'awaiting-health', 'rebuild', 't5_zombie_theater_1', null, 'Checking container state', '2026-01-01');
|
||||||
|
add('queued', 'queued', 'rebuild', 't5_zombie_theater_1', null, 'Request recorded', '2025-12-31');
|
||||||
|
const ids = () => listDeployments({ view: 'active' }).map(row => row.id);
|
||||||
|
const expected = ['old-inserted-last', 'z-first', 'a-second', 'queued'];
|
||||||
|
expect(ids()).toEqual(expected);
|
||||||
|
expect(ids()).toEqual(expected);
|
||||||
|
|
||||||
|
handle.prepare(`UPDATE server_deployments SET phase_key='verify-container', phase_started_at='2026-01-03',
|
||||||
|
observed_at='2026-01-03', observation_json=? WHERE id='z-first'`)
|
||||||
|
.run(JSON.stringify({ blocker: { code: 'native-host', message: 'Waiting for container readiness: starting' } }));
|
||||||
|
handle.prepare("UPDATE server_deployments SET phase_key='verify-plutonium', phase_started_at='2026-01-04' WHERE id='a-second'").run();
|
||||||
|
expect(ids()).toEqual(expected);
|
||||||
|
|
||||||
|
handle.prepare("UPDATE server_deployments SET state='done', finished_at='2026-01-05' WHERE id='z-first'").run();
|
||||||
|
handle.prepare("UPDATE server_deployments SET state='awaiting-health', phase_key='verify-game' WHERE id='queued'").run();
|
||||||
|
expect(ids()).toEqual(['queued', 'old-inserted-last', 'a-second']);
|
||||||
|
expect(listDeployments({ view: 'all' }).map(row => row.id)).toEqual(['a-second', 'z-first', 'old-inserted-last', 'queued']);
|
||||||
|
});
|
||||||
|
|
||||||
it('polls every active row and counts review outcomes without transferring retained history', async () => {
|
it('polls every active row and counts review outcomes without transferring retained history', async () => {
|
||||||
const { handle, add, REMOVING_SERVER } = await setup();
|
const { handle, add, REMOVING_SERVER } = await setup();
|
||||||
handle.transaction(() => {
|
handle.transaction(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user