/** * Fleet tab — multi-host roster cards (PearDock-style). */ /** * @typedef {{ * id: string, * publicKeyHex: string, * alias: string, * connected: boolean, * active: boolean, * reconnecting: boolean, * failed: boolean, * attempts: number, * maxAttempts: number, * invite: string|null, * capability: string|null, * adminSeed: string|null, * lastConnectedAt: number|null, * cpu?: number|null, * ram?: number|null, * health?: string|null, * }} FleetPeer */ /** * Merge saved peers + live connections into a sorted roster. * @param {{ * saved: Array, * live: Array<{ publicKeyHex: string, connected?: boolean }>, * activeId: string|null, * getReconnectInfo: (id: string) => { attempts: number, maxAttempts: number, reconnecting: boolean, failed: boolean }, * }} opts * @returns {FleetPeer[]} */ export function buildFleetRoster(opts) { const byId = new Map() for (const b of opts.saved || []) { const id = String(b.publicKeyHex || b.id || '').toLowerCase() if (!/^[0-9a-f]{64}$/.test(id)) continue byId.set(id, { id, publicKeyHex: id, alias: b.alias || '', connected: false, active: false, reconnecting: false, failed: false, attempts: 0, maxAttempts: 0, invite: b.invite || null, capability: b.capability || null, adminSeed: b.adminSeed || null, lastConnectedAt: b.lastConnectedAt || null, }) } for (const c of opts.live || []) { const id = String(c.publicKeyHex || '').toLowerCase() if (!/^[0-9a-f]{64}$/.test(id)) continue const prev = byId.get(id) || { id, publicKeyHex: id, alias: '', invite: null, capability: null, adminSeed: null, lastConnectedAt: null, } const info = opts.getReconnectInfo?.(id) || { attempts: 0, maxAttempts: 0, reconnecting: false, failed: false, } byId.set(id, { ...prev, connected: Boolean(c.connected), active: opts.activeId === id, reconnecting: Boolean(info.reconnecting) && !c.connected, failed: Boolean(info.failed) && !c.connected, attempts: info.attempts || 0, maxAttempts: info.maxAttempts || 0, }) } // Also surface reconnecting peers that dropped from connections map for (const [id, peer] of byId) { if (peer.connected) continue const info = opts.getReconnectInfo?.(id) if (!info) continue peer.reconnecting = Boolean(info.reconnecting) peer.failed = Boolean(info.failed) peer.attempts = info.attempts || 0 peer.maxAttempts = info.maxAttempts || 0 } const rank = (p) => { if (p.active && p.connected) return 0 if (p.connected) return 1 if (p.reconnecting) return 2 if (p.failed) return 3 return 4 } return [...byId.values()].sort((a, b) => { const d = rank(a) - rank(b) if (d) return d return (a.alias || a.id).localeCompare(b.alias || b.id) }) } /** * @param {FleetPeer[]} roster */ export function summarizeFleet(roster) { let live = 0 let reconnecting = 0 let failed = 0 let offline = 0 for (const p of roster) { if (p.connected) live++ else if (p.reconnecting) reconnecting++ else if (p.failed) failed++ else offline++ } return { total: roster.length, live, reconnecting, failed, offline } } /** * Render Fleet cards into a container. * @param {HTMLElement} root * @param {FleetPeer[]} roster * @param {{ * onActivate: (peer: FleetPeer) => void|Promise, * onReconnect: (peer: FleetPeer) => void|Promise, * onForget: (peer: FleetPeer) => void|Promise, * onOpenCharts: (peer: FleetPeer) => void|Promise, * onAlias: (peer: FleetPeer) => void|Promise, * onConnect?: () => void, * }} handlers */ export function renderFleetCards(root, roster, handlers) { if (!root) return root.innerHTML = '' if (!roster.length) { const empty = document.createElement('div') empty.className = 'fleet-empty' empty.innerHTML = `

No agents yet

Connect a public key or pd1. invite — saved agents appear here and restore on launch.

` const actions = empty.querySelector('.fleet-empty-actions') if (actions && handlers.onConnect) { const go = document.createElement('button') go.type = 'button' go.className = 'btn btn-primary' go.textContent = 'Connect an agent' go.addEventListener('click', () => handlers.onConnect?.()) actions.appendChild(go) } root.appendChild(empty) return } const grid = document.createElement('div') grid.className = 'fleet-grid' for (const peer of roster) { grid.appendChild(buildCard(peer, handlers)) } root.appendChild(grid) } /** * @param {FleetPeer} peer * @param {object} handlers */ function buildCard(peer, handlers) { const card = document.createElement('article') const state = peer.connected ? peer.active ? 'active' : 'online' : peer.reconnecting ? 'reconnecting' : peer.failed ? 'failed' : 'offline' card.className = `fleet-card fleet-card--${state}` card.dataset.fleetId = peer.id const title = peer.alias || `${peer.id.slice(0, 12)}…` const badge = badgeFor(state, peer) const health = healthLine(peer, state) const chips = metricChips(peer) card.innerHTML = `

${escapeHtml(title)}

${escapeHtml(peer.id.slice(0, 24))}…

${escapeHtml(badge)}
${chips}
  • Status${escapeHtml(health)}
  • Role${peer.active ? 'Active' : peer.connected ? 'Standby' : '—'}
  • Last seen${escapeHtml(formatLastSeen(peer))}
` const footer = card.querySelector('.fleet-card-actions') appendActions(footer, peer, state, handlers) card.querySelector('.fleet-card-title')?.addEventListener('dblclick', (ev) => { ev.stopPropagation() handlers.onAlias?.(peer) }) return card } function badgeFor(state, peer) { if (state === 'active') return 'Active' if (state === 'online') return 'Online' if (state === 'reconnecting') { return peer.maxAttempts ? `Retry ${peer.attempts}/${peer.maxAttempts}` : 'Retrying' } if (state === 'failed') return 'Failed' return 'Offline' } function healthLine(peer, state) { if (peer.health === 'critical') return 'Critical' if (peer.health === 'degraded') return 'Degraded' if (state === 'active' || state === 'online') return 'Connected' if (state === 'reconnecting') return 'Reconnecting…' if (state === 'failed') return 'Max reconnects reached' return 'Saved · not connected' } /** @param {FleetPeer} peer */ function metricChips(peer) { if (!peer.connected && peer.cpu == null && peer.ram == null) return '' const cpu = peer.cpu != null && Number.isFinite(Number(peer.cpu)) ? `${Number(peer.cpu).toFixed(0)}%` : '—' const ram = peer.ram != null && Number.isFinite(Number(peer.ram)) ? `${Number(peer.ram).toFixed(0)} MiB` : '—' return `
CPU${escapeHtml(cpu)} RAM${escapeHtml(ram)}
` } function formatLastSeen(peer) { if (peer.connected) return 'now' if (!peer.lastConnectedAt) return '—' const age = Math.max(0, Date.now() - Number(peer.lastConnectedAt)) if (age < 60_000) return 'just now' if (age < 3600_000) return `${Math.floor(age / 60_000)}m ago` if (age < 86400_000) return `${Math.floor(age / 3600_000)}h ago` return `${Math.floor(age / 86400_000)}d ago` } function appendActions(footer, peer, state, handlers) { if (!footer) return if (state === 'active') { const active = btn('Active', 'ghost', true) footer.appendChild(active) footer.appendChild( btn('Open Charts', 'ghost', false, () => handlers.onOpenCharts?.(peer)) ) } else if (state === 'online') { footer.appendChild( btn('Set active', 'primary', false, () => handlers.onActivate?.(peer)) ) footer.appendChild( btn('Charts', 'ghost', false, () => handlers.onOpenCharts?.(peer)) ) } else if (state === 'reconnecting') { footer.appendChild(btn('Retrying…', 'ghost', true)) } else { footer.appendChild( btn('Reconnect', state === 'failed' ? 'warn' : 'ghost', false, () => handlers.onReconnect?.(peer) ) ) } footer.appendChild( btn('Forget', 'danger-ghost', false, () => handlers.onForget?.(peer)) ) } function btn(label, kind, disabled, onClick) { const b = document.createElement('button') b.type = 'button' b.className = kind === 'primary' ? 'btn btn-primary' : kind === 'warn' ? 'btn btn-warn' : kind === 'danger-ghost' ? 'btn btn-ghost danger' : 'btn btn-ghost' b.textContent = label b.disabled = Boolean(disabled) if (onClick) { b.addEventListener('click', (ev) => { ev.stopPropagation() onClick() }) } return b } function escapeHtml(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') }