Repository Public
1/**2 * agentd `/api/cluster` payload → `ClusterOverview`. Pure and defensive: unknown fields are3 * ignored, missing values become null, and NOTHING that identifies the network or the operator4 * (IP addresses, `publicIP`, `lanMap`, usernames, hostnames, serials) is ever copied through.5 * Metrics older than 90 s make a node `stale` with health UNKNOWN — never ONLINE.6 *7 * Edges are limited to two documented facts and nothing else:8 * - `lan` host → every other Mac node in the inventory ("on the same LAN as the host")9 * - `tunnel` host → the public gateway server (BHS64), only when agentd lists that server10 */11import type { ClusterNode, ClusterOverview, Health } from '@cr/shared';1213export const STALE_MS = 90_000;14const IPV4 = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/;15const GATEWAY = 'BHS64';1617type Obj = Record<string, unknown>;18const obj = (v: unknown): Obj | null => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Obj) : null);19const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null);20/** A display string, dropped entirely if it could carry an address. */21const text = (v: unknown, max = 80): string | null =>22 typeof v === 'string' && v.trim() && !IPV4.test(v) && !v.includes('@') ? v.trim().slice(0, max) : null;23const firstNum = (...vs: unknown[]): number | null => {24 for (const v of vs) {25 const n = num(v);26 if (n !== null) return n;27 }28 return null;29};30const round = (n: number | null, digits = 1): number | null => (n === null ? null : Math.round(n * 10 ** digits) / 10 ** digits);3132function kindOf(n: Obj): ClusterNode['kind'] {33 const hint = `${text(n.kind) ?? ''} ${text(n.platform) ?? ''} ${text(n.model) ?? ''} ${text(n.hardware) ?? ''}`.toLowerCase();34 if (/iphone|ipad|ios|mobile/.test(hint)) return 'mobile';35 if (/server|linux|ubuntu|ovh/.test(hint)) return 'server';36 if (/storage|nas/.test(hint)) return 'storage';37 if (/mac|darwin|apple/.test(hint)) return 'mac';38 return 'other';39}4041function disk(m: Obj): { free: number | null; total: number | null } {42 const d = obj(m.disk) ?? {};43 let total = firstNum(m.diskTotalGB, m.diskTotalGb, d.totalGB, d.totalGb, d.total);44 let free = firstNum(m.diskFreeGB, m.diskFreeGb, m.diskAvailGB, d.freeGB, d.freeGb, d.free, d.availGB, d.avail);45 const used = firstNum(m.diskUsedGB, m.diskUsedGb, d.usedGB, d.usedGb, d.used);46 if (free === null && total !== null && used !== null) free = total - used;47 if (total === null && free !== null && used !== null) total = free + used;48 return { free: round(free), total: round(total) };49}5051function appNames(v: unknown): string[] {52 const list = Array.isArray(obj(v)?.apps) ? (obj(v)?.apps as unknown[]) : Array.isArray(v) ? v : [];53 const out: string[] = [];54 for (const a of list) {55 const name = typeof a === 'string' ? text(a) : text(obj(a)?.name) ?? text(obj(a)?.app);56 if (name) out.push(name);57 }58 return [...new Set(out)].slice(0, 100);59}6061export interface MapOptions {62 nowMs: number;63 /** When the payload was fetched (the snapshot's collection time). */64 fetchedAtMs: number;65 execHosts: readonly string[];66 /** Alias of the node Claude Remote runs on (CR_NODE_ALIAS) or its short hostname. */67 hostAlias: string | null;68}6970export function mapAgentd(payload: unknown, o: MapOptions): ClusterOverview {71 const root = obj(payload) ?? {};72 const metrics = obj(root.metrics) ?? {};73 const appsByNode = obj(root.appsByNode) ?? {};74 const hostKey = o.hostAlias?.toLowerCase() ?? null;75 const nodes: ClusterNode[] = [];76 const seen = new Set<string>();7778 const add = (raw: unknown, forcedKind?: ClusterNode['kind']): void => {79 const n = obj(raw);80 const id = text(n?.name, 40);81 if (!n || !id || seen.has(id)) return;82 seen.add(id);83 const m = obj(metrics[id]) ?? {};84 const ts = num(m.ts);85 const tsMs = ts === null ? null : ts > 1e11 ? ts : ts * 1000; // epoch seconds (ms tolerated)86 const stale = tsMs === null || o.nowMs - tsMs > STALE_MS;87 const status = typeof m.status === 'string' ? m.status.toLowerCase() : null;88 const health: Health = stale ? 'UNKNOWN' : status === 'online' ? 'ONLINE' : status === 'offline' ? 'OFFLINE' : status === 'degraded' ? 'DEGRADED' : 'UNKNOWN';89 const d = disk(m);90 const load = Array.isArray(m.load) ? num(m.load[0]) : null;91 nodes.push({92 id,93 model: text(n.model),94 chip: text(n.chip),95 kind: forcedKind ?? kindOf(n),96 role: text(n.role),97 cpuCores: num(n.cpuCores),98 gpuCores: num(n.gpuCores),99 memoryMb: firstNum(n.memoryMB, n.memoryMb, m.memTotalMB),100 os: text(n.platform) ?? text(n.os),101 health,102 metricsAt: tsMs === null ? null : new Date(tsMs).toISOString(),103 stale,104 cpuPct: round(num(m.cpu)),105 memUsedMb: round(firstNum(m.memUsedMB, m.memUsedMb), 0),106 diskFreeGb: d.free,107 diskTotalGb: d.total,108 load1: round(load, 2),109 apps: appNames(appsByNode[id]),110 execAllowed: o.execHosts.includes(id),111 isHost: hostKey !== null && id.toLowerCase() === hostKey,112 });113 };114 for (const n of Array.isArray(root.nodes) ? root.nodes : []) add(n);115 const servers = Array.isArray(root.servers) ? root.servers : Object.values(obj(root.servers) ?? {});116 for (const s of servers) add(s, 'server');117118 const host = nodes.find((n) => n.isHost);119 const edges: ClusterOverview['edges'] = [];120 if (host) {121 for (const n of nodes) if (n !== host && n.kind === 'mac') edges.push({ from: host.id, to: n.id, kind: 'lan' });122 if (nodes.some((n) => n.id === GATEWAY && n.kind === 'server')) edges.push({ from: host.id, to: GATEWAY, kind: 'tunnel' });123 }124 const snapshotFresh = o.nowMs - o.fetchedAtMs <= STALE_MS;125 const online = nodes.filter((n) => n.health === 'ONLINE').length;126 const health: Health = !snapshotFresh || nodes.length === 0 ? 'UNKNOWN' : online === nodes.length ? 'ONLINE' : 'DEGRADED';127 return {128 source: 'agentd',129 collectedAt: new Date(o.fetchedAtMs).toISOString(),130 health,131 nodes: snapshotFresh ? nodes : nodes.map((n) => ({ ...n, stale: true, health: 'UNKNOWN' as const })),132 edges,133 totals: {134 nodes: nodes.length,135 online: snapshotFresh ? online : 0,136 cores: nodes.reduce((s, n) => s + (n.cpuCores ?? 0), 0),137 memoryGb: Math.round(nodes.reduce((s, n) => s + (n.memoryMb ?? 0), 0) / 1024),138 },139 };140}141