SPB Git

Repository Public

mapping.ts 6.2 KB 141 lines typescript

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
Files main
Menu

Theme