333 lines
9.6 KiB
JavaScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
}
|