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(); 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; const versionStatuses: Record = { 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(); 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 = { 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)); }