Files
electric-cherry/src/lib/ui/provisioning-presentation.ts
T

227 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-plutonium': 'Verify Plutonium revision',
'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', 'verify-plutonium'] };
function definitions(action: ProvisioningAction, native = false): GroupDefinition[] {
if (native) {
const command: GroupDefinition = { key: 'command', label: `${actionLabels[action]} on host agent`, phases: ['container-action'] };
if (action === 'create' || action === 'rebuild') return [request,
{ key: 'create', label: action === 'rebuild' ? 'Rebuild on host agent' : 'Create and start on host agent', phases: ['startup-queue', 'prepare', 'container-action'] }, readiness];
if (action === 'destroy') return [request, command, { key: 'cleanup', label: 'Retire instance', phases: ['cleanup'] }];
}
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, detail.backend === 'native');
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-plutonium' ? 'plutonium-version' : 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 = detail.backend === 'native' && key === 'startup-queue' ? 'Admit to startup capacity'
: detail.backend === 'native' && key === 'prepare' ? 'Prepare launch payload'
: detail.backend === 'native' && key === 'container-action' ? `${actionLabels[detail.deployment.action]} instance through host agent`
: 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 (detail.cancellation) description = detail.cancellation.state === 'done'
? 'Creation was cancelled. Its instance files, container, and port reservations have been removed.'
: 'Creation was cancelled. Cleanup is tracked separately below; ports remain reserved until the host confirms removal.';
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 and awaiting provisioning evaluation. No capacity blocker has been recorded.';
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', 'offset']) {
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;
if (value.canCancelCreation !== undefined && typeof value.canCancelCreation !== 'boolean') return false;
if (value.cancellation !== undefined && value.cancellation !== null && !(record(value.cancellation)
&& text(value.cancellation.cleanupId) && text(value.cancellation.state) && text(value.cancellation.stage)
&& nullableText(value.cancellation.error))) return false;
if (value.backend !== undefined && !['native', 'komodo'].includes(value.backend as string)) return false;
if (value.workerActivity !== undefined && (!Array.isArray(value.workerActivity) || !value.workerActivity.every(item =>
record(item) && text(item.key) && text(item.label) && timestamp(item.startedAt)))) return false;
if (value.agentOperation !== undefined && value.agentOperation !== null && !(record(value.agentOperation)
&& text(value.agentOperation.id) && text(value.agentOperation.hostId) && text(value.agentOperation.state)
&& timestamp(value.agentOperation.updatedAt) && timestamp(value.agentOperation.createdAt) && nullableText(value.agentOperation.error))) 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', 'plutonium-release', 'plutonium-version', 'occupancy', 'stopped'].includes(check.key as string)
&& ['pass', 'waiting', 'fail', 'unknown'].includes(check.result as string)
&& [null, 'komodo', 'agent', 'rcon', 'telemetry', 'combined', 'manifest'].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;
}