Files
peardata/ui/fleet.js
T
Raven Scott 6d384534f2
CI / test (push) Successful in 58s
Release rolling / release (push) Successful in 7m11s
updates
2026-07-18 22:15:43 -04:00

333 lines
9.6 KiB
JavaScript

/**
* 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<object>,
* 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<void>,
* onReconnect: (peer: FleetPeer) => void|Promise<void>,
* onForget: (peer: FleetPeer) => void|Promise<void>,
* onOpenCharts: (peer: FleetPeer) => void|Promise<void>,
* onAlias: (peer: FleetPeer) => void|Promise<void>,
* 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 = `
<p class="fleet-empty-title">No agents yet</p>
<p class="muted">Connect a public key or <code>pd1.</code> invite — saved agents appear here and restore on launch.</p>
<p class="fleet-empty-actions"></p>
`
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 = `
<header class="fleet-card-head">
<div class="fleet-card-titles">
<h3 class="fleet-card-title" title="Double-click to rename">${escapeHtml(title)}</h3>
<p class="fleet-card-key muted">${escapeHtml(peer.id.slice(0, 24))}…</p>
</div>
<span class="fleet-badge fleet-badge--${state}">${escapeHtml(badge)}</span>
</header>
${chips}
<ul class="fleet-card-meta">
<li><span class="muted">Status</span><strong>${escapeHtml(health)}</strong></li>
<li><span class="muted">Role</span><strong>${peer.active ? 'Active' : peer.connected ? 'Standby' : '—'}</strong></li>
<li><span class="muted">Last seen</span><strong>${escapeHtml(formatLastSeen(peer))}</strong></li>
</ul>
<footer class="fleet-card-actions"></footer>
`
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 `<div class="fleet-card-chips" aria-label="Live metrics">
<span class="fleet-chip"><span class="muted">CPU</span><strong>${escapeHtml(cpu)}</strong></span>
<span class="fleet-chip"><span class="muted">RAM</span><strong>${escapeHtml(ram)}</strong></span>
</div>`
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}