87 lines
5.0 KiB
TypeScript
87 lines
5.0 KiB
TypeScript
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));
|
||
}
|