Files
electric-cherry/src/lib/hosts.ts
T

317 lines
9.9 KiB
TypeScript

import type { HostLibraryCache } from './game-files.js';
export type HostMode = 'active' | 'draining' | 'disabled';
export type HostReadiness =
| 'preparing'
| 'needs-runtime'
| 'needs-game-files'
| 'ready'
| 'degraded'
| 'draining'
| 'disabled'
| 'deleting';
export type HostOperationState =
'pending' | 'accepted' | 'running' | 'completed' | 'failed' | 'unknown';
export function hostReadinessLabel(readiness: HostReadiness): string {
return {
preparing: 'Preparing',
'needs-runtime': 'Needs runtime',
'needs-game-files': 'Needs game files',
ready: 'Ready',
degraded: 'Degraded',
draining: 'Draining',
disabled: 'Disabled',
deleting: 'Deleting'
}[readiness];
}
export interface HostDeletion {
allowed: boolean;
blockers: string[];
}
export interface HostDecommission {
operationId: string;
stage: 'queued' | 'running' | 'cleaned' | 'failed' | 'completed';
requestedAt: string;
updatedAt: string;
error: string | null;
retryable: boolean;
}
export function hostDeletionMessage(host: Pick<Host, 'connected' | 'decommission'>): string {
if (!host.decommission) return '';
switch (host.decommission.stage) {
case 'queued':
return host.connected
? 'Host cleanup is queued.'
: 'Waiting for the host to reconnect before removing its files and agent.';
case 'running':
return 'Removing host files, cached game libraries, and the agent installation.';
case 'cleaned':
return 'Host data is removed. Waiting for confirmation that the agent has uninstalled itself.';
case 'failed':
return 'Cleanup needs attention. Complete removal has not been confirmed.';
case 'completed':
return 'Host files and agent removal confirmed.';
}
}
export interface HostLibrary {
id: string;
state: 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated';
progress?: number;
error?: string;
}
export interface HostAgentUpdate {
enabled: boolean;
state: 'current' | 'checking' | 'deferred' | 'updating' | 'updated' | 'failed';
currentVersion: string;
targetVersion?: string;
error?: string;
checkedAt?: string;
}
export interface HostAgentLane {
name: 'health-1' | 'health-2' | 'read' | 'write';
alive: boolean;
busy: boolean;
lastPollAt: string | null;
pollAgeSeconds: number;
}
export function hostAgentLaneLabel(name: HostAgentLane['name']): string {
return {
'health-1': 'Health check 1',
'health-2': 'Health check 2',
read: 'File reads',
write: 'Actions'
}[name];
}
export function hostAgentLaneState(
lane: HostAgentLane
): 'stopped' | 'stalled' | 'busy' | 'starting' | 'polling' {
if (!lane.alive) return 'stopped';
// Reads and mutations can legitimately take minutes. Health work has a
// bounded budget; an old poll there indicates a blocked worker even if busy.
if (lane.pollAgeSeconds > 90 && (!lane.busy || lane.name.startsWith('health-')))
return 'stalled';
return lane.busy ? 'busy' : lane.lastPollAt === null ? 'starting' : 'polling';
}
export function hostAgentUpdatesEnabled(inventory: HostInventory): boolean {
return (
inventory.agentUpdate?.enabled === true &&
inventory.capabilities?.includes('agent.update') === true
);
}
export function hostAgentUpdateLabel(inventory: HostInventory): string {
if (!hostAgentUpdatesEnabled(inventory)) return 'Setup required';
const update = inventory.agentUpdate!;
if (update.state === 'current' && !update.checkedAt) return 'Awaiting first check';
return {
current: 'Current',
checking: 'Checking',
deferred: 'Deferred',
updating: 'Updating',
updated: 'Updated',
failed: 'Needs attention'
}[update.state];
}
export function hostAgentUpdateMessage(inventory: HostInventory): string {
if (!hostAgentUpdatesEnabled(inventory))
return 'Run the current installer once on this host to enable automatic agent updates.';
const update = inventory.agentUpdate!;
switch (update.state) {
case 'current':
return update.checkedAt
? 'The last check found no newer compatible agent release.'
: 'The host timer will check for a compatible agent release automatically.';
case 'checking':
return 'The host is checking the controller release and verifying its download.';
case 'deferred':
return 'The updater is waiting for the reported condition to clear. The host checks again automatically.';
case 'updating':
return 'The host is replacing agent code and checking that the new process connects successfully.';
case 'updated':
return 'The new agent connected successfully. Automatic checks continue.';
case 'failed':
return 'The update did not complete successfully. Review the reported error before retrying setup.';
}
}
export interface HostInventory {
hostname?: string;
agentVersion?: string;
protocolVersion?: number;
os?: string;
architecture?: string;
cpu?: string;
cpuCount?: number;
cpuUsagePercent?: number;
memoryTotal?: number;
memoryAvailable?: number;
storageTotal?: number;
storageAvailable?: number;
dockerVersion?: string;
dockerRunning?: boolean;
directoriesWritable?: boolean;
runtimeImages?: string[];
libraries?: HostLibrary[];
currentOperation?: string | null;
[key: string]: unknown;
capabilities?: string[];
plutoniumCache?: { revision: number; bytes: number };
agentUpdate?: HostAgentUpdate;
agentLanes?: HostAgentLane[];
instanceResources?: InstanceResources[];
instanceResourcesError?: string;
instanceStates?: InstanceState[];
}
export interface InstanceState {
instanceId: string;
generation: number;
state: string;
healthy: boolean;
observedAt: string;
}
export interface InstanceResources {
instanceId: string;
generation: number;
uptimeSeconds: number;
cpuCores: number;
memoryBytes: number;
}
export interface ResourceAllowance {
cpuCores: number;
memoryBytes: number;
source: 'host' | 'fleet' | 'bootstrap';
samples: number;
observedAt: string | null;
}
export interface CapacityCalibration {
running: ResourceAllowance;
startup: ResourceAllowance;
}
export interface CapacitySample {
hostId: string;
hostName: string | null;
instanceId: string;
server: string | null;
generation: number;
phase: 'startup' | 'running';
timestamp: string;
cpuCores: number;
memoryBytes: number;
}
export interface CapacitySamplePage {
samples: CapacitySample[];
total: number;
page: number;
pageSize: number;
}
export interface Host {
id: string;
name: string;
hostname: string;
mode: HostMode;
portMin: number;
portMax: number;
bindAddress: string;
publicAddress: string;
minimumFreeBytes: number;
createdAt: string;
lastHeartbeat: string | null;
connected: boolean;
connectionState: 'online' | 'offline';
readiness: HostReadiness;
readinessReasons: string[];
inventory: HostInventory;
enrolled: boolean;
credentialActive: boolean;
decommission: HostDecommission | null;
preparation?: HostPreparation;
enablingSince?: string | null;
}
export interface HostOperation {
id: string;
hostId: string;
kind: string;
instanceId: string | null;
generation: number | null;
payload: Record<string, unknown>;
state: HostOperationState;
result: unknown;
error: string | null;
createdAt: string;
updatedAt: string;
}
export interface HostOperationSummary extends Omit<HostOperation, 'payload' | 'result'> {
hostName: string | null;
serverName: string | null;
targetPath: string | null;
source: string | null;
routine: boolean;
}
export type HostOperationFilter = 'important' | 'routine' | 'all';
export interface HostOperationScope {
host?: string;
server?: string;
operation?: string;
}
export function hostOperationScope(params: URLSearchParams): HostOperationScope {
const scope: HostOperationScope = {};
for (const key of ['host', 'server', 'operation'] as const) {
const value = params.get(key);
// eslint-disable-next-line no-control-regex -- Reject control characters in operation scope filters.
if (value && value.length <= 512 && !/[\x00-\x1f\x7f]/.test(value)) scope[key] = value;
}
return scope;
}
export function isHostOperationFilter(value: unknown): value is HostOperationFilter {
return typeof value === 'string' && ['important', 'routine', 'all'].includes(value);
}
export interface HostOperationHistoryPage {
operations: HostOperationSummary[];
page: number;
pageSize: 25;
total: number;
totalPages: number;
filter: HostOperationFilter;
}
export interface HostPreparationItem {
kind: 'runtime' | 'library' | 'plutonium';
label: string;
state: 'waiting' | 'preparing' | 'ready' | 'blocked';
detail: string;
operationId?: string;
bytesDownloaded?: number;
bytesTotal?: number;
cache?: HostLibraryCache;
}
export interface HostPreparation {
state: HostPreparationItem['state'];
items: HostPreparationItem[];
}
export interface NativeDeploymentSelection {
libraryIds: Partial<Record<'t5' | 't6', string>>;
image: string;
bindAddress?: string;
publicAddress?: string;
publicPort?: number;
}
/** Queued automatic launches have no assigned host or port until provisioning. */
export interface NativePlacement {
hostId?: string;
libraryId: string;
image?: string;
bindAddress?: string;
publicAddress?: string;
publicPort?: number;
automatic?: boolean;
network?: { bindAddress?: string; publicAddress?: string; publicPort?: number };
}