Clarify server actions and group maintenance failures

This commit is contained in:
2026-10-07 19:21:53 -04:00
parent bcecc42dd1
commit c39563daef
15 changed files with 559 additions and 111 deletions
+86
View File
@@ -0,0 +1,86 @@
import type { MaintenanceJob, PlutoniumVersion } from '../plutonium.js';
import type { StatusTone } from './status.js';
export interface ScanErrorGroup {
key: string;
title: string;
description: string;
servers: string[];
details: { server: string | null; message: string }[];
}
const scanCauses = [
{ key: 'host', pattern: /(?:host|agent).*(?:offline|unavailable|disconnect|not connected)/i,
title: 'Host agent unavailable', description: 'The host agent could not complete the checks. Check its connection, then retry.' },
{ key: 'timeout', pattern: /timed?\s*out|timeout|no reply/i,
title: 'Checks timed out', description: 'The check did not receive a response in time. Check the server connection, then retry.' },
{ key: 'permissions', pattern: /permission denied|access denied|EACCES|EPERM/i,
title: 'File access denied', description: 'The host agent could not read files needed for the check. Review file permissions on the affected host.' },
{ key: 'files', pattern: /no such file|file.*not found|ENOENT|missing (?:file|directory)|cannot find.*file/i,
title: 'Files unavailable', description: 'Files needed for the check could not be found. Review the affected server’s files and retry.' },
{ key: 'response', pattern: /bad.reply|invalid (?:response|reply)|malformed|could not parse/i,
title: 'Unexpected response', description: 'The response could not be read. Inspect the connection and host logs before retrying.' },
{ key: 'setup', pattern: /register a native host instance/i,
title: 'No managed servers', description: 'Register a host and create a server before running automatic maintenance checks.' }
];
const otherCause = { key: 'other', title: 'Other check failures', description: 'Some checks could not finish. Review the technical details before retrying.' };
/** Worker errors use "server: detail" lines. Only known names become links. */
export function groupMaintenanceScanErrors(raw: string | null | undefined, knownServers: readonly string[]): ScanErrorGroup[] {
if (!raw?.trim()) return [];
const names = [...new Set(knownServers)].sort((a, b) => b.length - a.length);
const groups = new Map<string, ScanErrorGroup>();
for (const line of raw.split(/\r?\n/).map(value => value.trim()).filter(Boolean)) {
const server = names.find(name => line.startsWith(`${name}:`)) ?? null;
const message = server ? line.slice(server.length + 1).trim() : line;
const cause = scanCauses.find(cause => cause.pattern.test(message)) ?? otherCause;
let group = groups.get(cause.key);
if (!group) {
group = { key: cause.key, title: cause.title, description: cause.description, servers: [], details: [] };
groups.set(cause.key, group);
}
if (server && !group.servers.includes(server)) group.servers.push(server);
if (!group.details.some(detail => detail.server === server && detail.message === message)) group.details.push({ server, message });
}
return [...groups.values()];
}
type VersionStatus = NonNullable<PlutoniumVersion['update_status']>;
const versionStatuses: Record<VersionStatus, { label: string; tone: StatusTone }> = {
current: { label: 'Up to date', tone: 'online' },
outdated: { label: 'Update available', tone: 'warning' },
ahead: { label: 'Ahead of published release', tone: 'neutral' },
stopped: { label: 'Intentionally stopped', tone: 'neutral' },
unknown: { label: 'Unverified', tone: 'warning' }
};
export interface MaintenanceVersionGroup {
key: string;
revision: number | null;
status: VersionStatus;
label: string;
tone: StatusTone;
versions: PlutoniumVersion[];
pending: MaintenanceJob[];
updatable: string[];
}
/** Keep verification and pending work distinct even when revision numbers match. */
export function groupMaintenanceVersions(versions: readonly PlutoniumVersion[], jobs: readonly MaintenanceJob[]): MaintenanceVersionGroup[] {
const groups = new Map<string, MaintenanceVersionGroup>();
const pending = jobs.filter(job => !job.server_deleted_at && ['queued', 'restarting'].includes(job.state));
for (const version of versions) {
const status = version.update_status ?? (version.stopped ? 'stopped' : 'unknown');
const key = `${version.revision ?? 'unknown'}:${status}`;
let group = groups.get(key);
if (!group) {
group = { key, revision: version.revision, status, ...versionStatuses[status], versions: [], pending: [], updatable: [] };
groups.set(key, group);
}
group.versions.push(version);
const work = pending.filter(job => job.server === version.server);
group.pending.push(...work);
if (status === 'outdated' && !work.length) group.updatable.push(version.server);
}
const order: Record<VersionStatus, number> = { unknown: 0, outdated: 1, current: 2, ahead: 3, stopped: 4 };
return [...groups.values()].sort((a, b) => order[a.status] - order[b.status] || (b.revision ?? 0) - (a.revision ?? 0));
}