Complete provisioning job detail pages and lifecycle evidence
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
import { provisioningPhases, type ProvisioningDetail, type ProvisioningEvent, type ProvisioningEventPage, type ProvisioningPhase } from '$lib/provisioning-detail.js';
|
||||
import { actionCompleted, actionLabels, provisioningActions, type ProvisioningAction } from '$lib/provisioning.js';
|
||||
|
||||
export const activeProvisioningStates = ['queued', 'waiting', 'running', 'awaiting-health'];
|
||||
export type ProgressState = 'done' | 'current' | 'waiting' | 'failed' | 'pending' | 'cancelled' | 'unknown';
|
||||
export interface ProgressStep { key: ProvisioningPhase; label: string; state: ProgressState; at: string | null }
|
||||
export interface ProgressGroup { key: string; label: string; steps: ProgressStep[]; state: ProgressState }
|
||||
|
||||
const phaseLabels: Record<ProvisioningPhase, string> = {
|
||||
requested: 'Request recorded', eligibility: 'Check eligibility and capacity', 'startup-queue': 'Wait for a startup slot',
|
||||
prepare: 'Prepare server files', 'create-deployment': 'Create Komodo deployment', 'container-action': 'Start container',
|
||||
'remove-container': 'Stop and remove container', 'delete-deployment': 'Delete Komodo deployment', cleanup: 'Remove files and registration',
|
||||
'verify-container': 'Confirm container readiness', 'verify-game': 'Verify game status', 'verify-map': 'Verify selected engine map',
|
||||
'verify-stopped': 'Confirm container is stopped'
|
||||
};
|
||||
type GroupDefinition = { key: string; label: string; phases: ProvisioningPhase[] };
|
||||
const request: GroupDefinition = { key: 'request', label: 'Request and eligibility', phases: ['requested', 'eligibility'] };
|
||||
const creation: GroupDefinition = { key: 'create', label: 'Create and start', phases: ['startup-queue', 'prepare', 'create-deployment', 'container-action'] };
|
||||
const removal: GroupDefinition = { key: 'remove', label: 'Remove the instance', phases: ['remove-container', 'delete-deployment', 'cleanup'] };
|
||||
const readiness: GroupDefinition = { key: 'verify', label: 'Verify readiness', phases: ['verify-container', 'verify-game', 'verify-map'] };
|
||||
function definitions(action: ProvisioningAction): GroupDefinition[] {
|
||||
if (action === 'create') return [{ ...request, phases: ['requested'] }, creation, readiness];
|
||||
if (action === 'rebuild') return [request, removal, { ...creation, label: 'Create a fresh instance' }, readiness];
|
||||
if (action === 'destroy') return [request, removal];
|
||||
return [request, { key: 'command', label: `${actionLabels[action]} the existing container`, phases: ['container-action'] },
|
||||
{ ...readiness, phases: action === 'stop' ? ['verify-stopped'] : ['verify-container', 'verify-game'] }];
|
||||
}
|
||||
|
||||
export function provisioningGroups(detail: ProvisioningDetail): ProgressGroup[] {
|
||||
const progress = detail.progress;
|
||||
if (!progress) return [];
|
||||
const groups = definitions(detail.deployment.action);
|
||||
const phases = groups.flatMap(group => group.phases);
|
||||
const current = progress.phase ? phases.indexOf(progress.phase) : -1;
|
||||
const active = activeProvisioningStates.includes(detail.deployment.state);
|
||||
return groups.map(group => {
|
||||
const steps = group.phases.map((key): ProgressStep => {
|
||||
const checkpoint = progress.checkpoints.find(item => item.key === key);
|
||||
const checkKey = key === 'verify-container' ? 'container' : key === 'verify-game' ? 'game' : key === 'verify-map' ? 'map' : key === 'verify-stopped' ? 'stopped' : null;
|
||||
const check = checkKey ? progress.checks.find(item => item.key === checkKey) : null;
|
||||
const index = phases.indexOf(key);
|
||||
const isCurrent = key === progress.phase;
|
||||
const state: ProgressState = check ? ({ pass: 'done', fail: 'failed', waiting: 'waiting', unknown: 'unknown' } as const)[check.result]
|
||||
: checkpoint ? 'done'
|
||||
: isCurrent && ['failed', 'interrupted'].includes(detail.deployment.state) ? 'failed'
|
||||
: isCurrent && active ? progress.blocker || ['waiting', 'queued'].includes(detail.deployment.state) ? 'waiting' : 'current'
|
||||
: current >= 0 && index >= current && detail.deployment.state === 'cancelled' ? 'cancelled'
|
||||
: active && current >= 0 && index > current ? 'pending' : 'unknown';
|
||||
const label = key === 'container-action' && ['start', 'restart', 'stop'].includes(detail.deployment.action)
|
||||
? `${actionLabels[detail.deployment.action]} existing container` : phaseLabels[key];
|
||||
return { key, label, state, at: check?.checkedAt ?? checkpoint?.at ?? (isCurrent ? progress.phaseStartedAt : null) };
|
||||
});
|
||||
const states = steps.map(step => step.state);
|
||||
const state = (['failed', 'current', 'waiting'] as const).find(value => states.includes(value))
|
||||
?? (states.every(value => value === 'done') ? 'done' : states.includes('unknown') ? 'unknown' : states.includes('cancelled') ? 'cancelled' : 'pending');
|
||||
return { key: group.key, label: group.label, steps, state };
|
||||
});
|
||||
}
|
||||
|
||||
export const progressStateLabels: Record<ProgressState, string> = {
|
||||
done: 'Completed', current: 'In progress', waiting: 'Waiting', failed: 'Needs attention', pending: 'Not started', cancelled: 'Not run', unknown: 'Not recorded'
|
||||
};
|
||||
export const progressIcons: Record<ProgressState, string> = { done: '✓', current: '•', waiting: '◷', failed: '!', pending: '○', cancelled: '–', unknown: '?' };
|
||||
|
||||
export function provisioningOutcome(detail: ProvisioningDetail) {
|
||||
const { deployment: operation, progress } = detail;
|
||||
const active = activeProvisioningStates.includes(operation.state);
|
||||
const failure = ['failed', 'interrupted'].includes(operation.state);
|
||||
const headline = operation.state === 'done' ? actionCompleted[operation.action]
|
||||
: operation.state === 'cancelled' ? `${actionLabels[operation.action]} request cancelled`
|
||||
: failure ? `${actionLabels[operation.action]} ${operation.state === 'interrupted' ? 'interrupted' : 'failed'}`
|
||||
: operation.state === 'warning' ? `${actionLabels[operation.action]} completed with warnings`
|
||||
: progress?.blocker?.message ?? operation.stage;
|
||||
let description: string;
|
||||
if (detail.canResumeCleanup) description = 'The remote deployment was deleted. Resume the remaining local cleanup without repeating container removal.';
|
||||
else if (detail.canResumeRebuild) description = 'The remote deployment was deleted. Resume local cleanup, then create a fresh instance in the reserved server slot.';
|
||||
else if (detail.canResumeVerification) description = 'Resume observation of the existing instance. This does not create, restart, or rebuild a container.';
|
||||
else if (operation.state === 'cancelled') description = 'This request is cancelled. Recorded prior work is preserved below; remaining work will not run.';
|
||||
else if (failure) description = progress?.command?.state === 'uncertain'
|
||||
? 'The remote response was uncertain. Inspect the recorded result and server before requesting another operation.'
|
||||
: 'Review the recorded failure and execution evidence before submitting another operation.';
|
||||
else if (operation.state === 'warning') description = 'The operation finished. Review the recorded warnings below for any remaining issue.';
|
||||
else if (operation.state === 'done') description = !progress
|
||||
? 'This operation was recorded as completed. Detailed verification evidence was not recorded.'
|
||||
: operation.action === 'destroy' ? 'The instance removal completed. This record preserves its results after deletion.'
|
||||
: operation.action === 'stop' ? 'The container was confirmed stopped. Its files and registration were retained.'
|
||||
: 'The operation completed its readiness checks. These recorded results describe that attempt; current health is available on the server page.';
|
||||
else if (active && progress?.phase === 'cleanup' && !progress.blocker) description = operation.action === 'rebuild'
|
||||
? 'Remote deployment deletion was confirmed. Local cleanup must finish before a fresh instance is created in the reserved slot.'
|
||||
: 'Remote deployment deletion was confirmed. The remaining local files and registration cleanup will continue without repeating container removal.';
|
||||
else if (progress?.blocker) description = progress.blocker.automatic
|
||||
? 'The worker will recheck this condition and continue automatically when eligible.'
|
||||
: 'Review the blocking condition below before this operation can continue.';
|
||||
else if (operation.state === 'queued') description = 'The request is saved. It will start when its dependencies and startup capacity allow.';
|
||||
else if (operation.state === 'waiting') description = 'Waiting for the worker’s eligibility checks. The recorded stage below describes the last known condition.';
|
||||
else description = operation.action === 'destroy' ? 'Removal is in progress. Confirmed checkpoints and remaining cleanup are recorded below.'
|
||||
: operation.action === 'rebuild' ? 'The old instance is removed before a fresh instance is created in its reserved slot. Follow each checkpoint below.'
|
||||
: 'The worker is executing this request and checking its resulting state.';
|
||||
return { active, headline, description };
|
||||
}
|
||||
|
||||
/** Accept return context only from the local operation surfaces we expose. */
|
||||
export function provisioningReturn(value: string | null | undefined): { href: string; label: string } {
|
||||
const fallback = { href: '/provisioning', label: 'Back to Provisioning' };
|
||||
if (!value || !value.startsWith('/') || value.startsWith('//') || value.includes('\\')) return fallback;
|
||||
try {
|
||||
const url = new URL(value, 'https://electric-cherry.invalid');
|
||||
if (url.origin !== 'https://electric-cherry.invalid') return fallback;
|
||||
if (url.pathname === '/provisioning') {
|
||||
const params = new URLSearchParams();
|
||||
for (const key of ['view', 'action', 'status', 'server', 'limit']) {
|
||||
const item = url.searchParams.get(key);
|
||||
if (item) params.set(key, item);
|
||||
}
|
||||
return { href: '/provisioning' + (params.size ? `?${params}` : ''), label: 'Back to Provisioning' };
|
||||
}
|
||||
const match = url.pathname.match(/^\/(campaigns|betas|maintenance|collections\/launches)\/[^/]+$/);
|
||||
if (match) return { href: url.pathname, label: `Back to ${match[1] === 'collections/launches' ? 'collection' : match[1] === 'campaigns' ? 'campaign' : match[1] === 'betas' ? 'beta' : 'maintenance'}` };
|
||||
} catch { /* Fall back to the local list. */ }
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function elapsedTime(start: string | null | undefined, end: number): string {
|
||||
if (!start || !Number.isFinite(Date.parse(start))) return 'Not recorded';
|
||||
const seconds = Math.max(0, Math.floor((end - Date.parse(start)) / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
return `${Math.floor(seconds / 3600)}h ${Math.floor(seconds % 3600 / 60)}m`;
|
||||
}
|
||||
|
||||
const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
const text = (value: unknown): value is string => typeof value === 'string';
|
||||
const number = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
|
||||
const nullableText = (value: unknown) => value === null || text(value);
|
||||
const timestamp = (value: unknown): value is string => text(value) && Number.isFinite(Date.parse(value));
|
||||
const nullableTime = (value: unknown) => value === null || timestamp(value);
|
||||
const scalar = (value: unknown) => value === null || text(value) || number(value);
|
||||
const localLink = (value: unknown) => value === null || (text(value) && value.startsWith('/') && !value.startsWith('//') && !value.includes('\\'));
|
||||
const optionalLink = (value: unknown) => value === undefined || localLink(value);
|
||||
const phase = (value: unknown) => value === null || provisioningPhases.includes(value as ProvisioningPhase);
|
||||
const event = (value: unknown): value is ProvisioningEvent => record(value) && number(value.id) && timestamp(value.created_at) && text(value.message);
|
||||
|
||||
export function isProvisioningEventPage(value: unknown): value is ProvisioningEventPage {
|
||||
return record(value) && Array.isArray(value.events) && value.events.every(event)
|
||||
&& number(value.total) && number(value.totalEvents) && (value.latestId === null || number(value.latestId))
|
||||
&& number(value.watermark) && (value.nextCursor === null || number(value.nextCursor));
|
||||
}
|
||||
|
||||
/** Validate every nested shape consumed by the page before replacing its last good snapshot. */
|
||||
export function isProvisioningDetail(value: unknown, id: string): value is ProvisioningDetail {
|
||||
if (!record(value) || !record(value.deployment)) return false;
|
||||
const operation = value.deployment;
|
||||
if (operation.id !== id || !provisioningActions.includes(operation.action as ProvisioningAction)
|
||||
|| !['server', 'state', 'stage', 'triggered_by', 'warnings'].every(key => text(operation[key]))
|
||||
|| !timestamp(operation.started_at) || !nullableTime(operation.finished_at) || !nullableTime(operation.server_deleted_at)
|
||||
|| !nullableText(operation.error) || !number(operation.server_exists)
|
||||
|| (operation.submitted_at !== undefined && !nullableTime(operation.submitted_at))
|
||||
|| (operation.expected_map !== undefined && !nullableText(operation.expected_map))) return false;
|
||||
if (!timestamp(value.generatedAt) || !Array.isArray(value.events) || !value.events.every(event)
|
||||
|| !Array.isArray(value.warningMessages) || !value.warningMessages.every(text)
|
||||
|| !['canResumeVerification', 'canResumeCleanup', 'canResumeRebuild', 'canCancelRemoval'].every(key => typeof value[key] === 'boolean')
|
||||
|| !nullableText(value.blueprintName)) return false;
|
||||
if (!record(value.activity) || !number(value.activity.total) || !(value.activity.latestId === null || number(value.activity.latestId))
|
||||
|| !(value.activity.failureExcerpt === null || event(value.activity.failureExcerpt))) return false;
|
||||
if (!Array.isArray(value.sources) || !value.sources.every(source => record(source)
|
||||
&& ['campaign', 'beta', 'maintenance', 'watchdog'].includes(source.kind as string)
|
||||
&& text(source.source_id) && text(source.label) && localLink(source.href))) return false;
|
||||
if (!(value.collection === null || record(value.collection) && text(value.collection.id) && text(value.collection.name))) return false;
|
||||
if (!record(value.capacity) || !timestamp(value.capacity.evaluatedAt) || !Array.isArray(value.capacity.pools)
|
||||
|| !value.capacity.pools.every(pool => record(pool) && ['startup', 'restart'].includes(pool.kind as string)
|
||||
&& number(pool.limit) && number(pool.used) && typeof pool.ownsSlot === 'boolean' && (pool.requestOrder === null || number(pool.requestOrder))
|
||||
&& Array.isArray(pool.holders) && pool.holders.every(holder => record(holder) && nullableText(holder.id) && text(holder.server)))) return false;
|
||||
if (value.recipe !== null) {
|
||||
const recipe = value.recipe;
|
||||
if (!record(recipe) || !['t5', 't6'].includes(recipe.game as string) || !timestamp(recipe.capturedAt)
|
||||
|| !['mapSlug', 'mapName', 'expectedMap', 'image', 'configFile', 'configSource', 'fingerprint'].every(key => text(recipe[key]))
|
||||
|| !Array.isArray(recipe.files) || !recipe.files.every(file => record(file)
|
||||
&& ['name', 'kind', 'path', 'sha256'].every(key => text(file[key])) && number(file.size) && (file.version === null || number(file.version)))) return false;
|
||||
}
|
||||
if (value.progress !== null) {
|
||||
const progress = value.progress;
|
||||
if (!record(progress) || progress.version !== 1 || !phase(progress.phase) || !nullableTime(progress.phaseStartedAt) || !nullableTime(progress.observedAt)
|
||||
|| ![null, 'queued', 'attempted'].includes(progress.recipeStatus as null | string)) return false;
|
||||
if (!(progress.blocker === null || record(progress.blocker) && text(progress.blocker.code) && text(progress.blocker.message)
|
||||
&& typeof progress.blocker.automatic === 'boolean' && optionalLink(progress.blocker.href)
|
||||
&& (progress.blocker.label === undefined || nullableText(progress.blocker.label)))) return false;
|
||||
if (!Array.isArray(progress.checks) || !progress.checks.every(check => record(check)
|
||||
&& ['container', 'game', 'map', 'occupancy', 'stopped'].includes(check.key as string)
|
||||
&& ['pass', 'waiting', 'fail', 'unknown'].includes(check.result as string)
|
||||
&& [null, 'komodo', 'rcon', 'telemetry', 'combined'].includes(check.source as null | string)
|
||||
&& text(check.label) && timestamp(check.checkedAt) && nullableTime(check.receivedAt)
|
||||
&& scalar(check.expected) && scalar(check.actual) && nullableText(check.message))) return false;
|
||||
if (!Array.isArray(progress.checkpoints) || !progress.checkpoints.every(checkpoint => record(checkpoint)
|
||||
&& text(checkpoint.key) && text(checkpoint.label) && timestamp(checkpoint.at) && phase(checkpoint.phase))) return false;
|
||||
if (!(progress.command === null || record(progress.command) && ['attempted', 'accepted', 'rejected', 'uncertain'].includes(progress.command.state as string)
|
||||
&& timestamp(progress.command.at) && text(progress.command.message))) return false;
|
||||
if (!(progress.recovery === null || record(progress.recovery) && ['held', 'released', 'transferred', 'unknown'].includes(progress.recovery.state as string)
|
||||
&& timestamp(progress.recovery.at) && text(progress.recovery.message)
|
||||
&& (progress.recovery.operationId === undefined || nullableText(progress.recovery.operationId)))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user