Files
peardata/app.js
T
Raven Scott 32928f19bd
CI / test (push) Successful in 1m8s
Release rolling / release (push) Successful in 8m25s
First Try: QVAC (QuantumVerse Automatic Computer)
2026-07-30 13:42:14 -04:00

1746 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* PearData desktop — multi-view shell, live charts, settings.
*/
import { manager } from './client/manager.js'
import { Methods, Pushes, Roles } from './shared/protocol.js'
import { getClientIdentity } from './client/identity.js'
import {
loadBookmarks,
upsertBookmark,
removeBookmark,
setBookmarkAlias,
} from './client/bookmarks.js'
import {
loadPeers,
upsertPeer,
getLastActivePeerId,
setLastActivePeerId,
} from './client/peerCache.js'
import { classifyConnectionInput } from './shared/crypto-auth.js'
import {
loadSettings,
saveSettings,
applySettingsToDom,
} from './client/settings.js'
import { drawChart, CHART_PALETTE, pushRing } from './ui/charts.js'
import { defaultModeFromMeta } from './shared/chart-types.js'
import { createMetricsDashboard } from './ui/dashboard.js'
import { getChartFocus } from './ui/chart-focus.js'
import { createQvacView } from './ui/qvac/index.js'
import {
buildFleetRoster,
summarizeFleet,
renderFleetCards,
} from './ui/fleet.js'
import { createLogsView } from './ui/logs.js'
import { createProcessesView } from './ui/processes.js'
import { createDataManager } from './ui/data-manager.js'
import { formatMib, formatKilobitsPerSec } from './shared/format.js'
import { chartOptionLabel } from './shared/container-names.js'
const $ = (id) => document.getElementById(id)
const els = {
connectInput: $('connect-input'),
peerAlias: $('peer-alias'),
adminSeed: $('admin-seed'),
btnConnect: $('btn-connect'),
btnDisconnect: $('btn-disconnect'),
btnInvite: $('btn-invite'),
btnRefreshMeta: $('btn-refresh-meta'),
compareToggle: $('compare-toggle'),
exploreChart: $('explore-chart'),
chartCpuLabel: $('chart-cpu-label'),
legendCpu: $('legend-cpu'),
serverInfo: $('server-info'),
log: $('log'),
status: $('status-chip'),
connMeta: $('conn-meta'),
roleBadge: $('role-badge'),
activePeerChip: $('active-peer-chip'),
inviteOut: $('invite-out'),
anomalyList: $('anomaly-list'),
offlineBanner: $('offline-banner'),
restoringBanner: $('restoring-banner'),
fleetStrip: $('fleet-strip'),
fleetSummary: $('fleet-summary'),
fleetChildren: $('fleet-children'),
notifyDesktop: $('notify-desktop'),
panelCpu: $('panel-cpu'),
panelRam: $('panel-ram'),
panelNet: $('panel-net'),
panelIo: $('panel-io'),
panelLoad: $('panel-load'),
panelExplore: $('panel-explore'),
statCpu: $('stat-cpu'),
statRam: $('stat-ram'),
statLoad: $('stat-load'),
statNet: $('stat-net'),
statHealth: $('stat-health'),
chartSearch: $('chart-search'),
metricsToc: $('metrics-toc'),
metricsWall: $('metrics-wall'),
metricsPlay: $('metrics-play'),
metricsPresets: $('metrics-presets'),
metricsMeta: $('metrics-meta'),
metricsHover: $('metrics-hover'),
metricsLive: $('metrics-live'),
metricsLiveLabel: $('metrics-live-label'),
metricsRetentionHint: $('metrics-retention-hint'),
fleetCards: $('fleet-cards'),
fleetRefreshBtn: $('fleet-refresh-btn'),
fleetStatLive: $('fleet-stat-live'),
fleetStatRetry: $('fleet-stat-retry'),
fleetStatOffline: $('fleet-stat-offline'),
fleetStatFailed: $('fleet-stat-failed'),
collapseSidebarBtn: $('collapse-sidebar-btn'),
settingReduceMotion: $('setting-reduce-motion'),
settingChartPoints: $('setting-chart-points'),
settingDefaultExplore: $('setting-default-explore'),
settingNotify: $('setting-notify'),
settingQvacProfile: $('setting-qvac-profile'),
settingQvacRag: $('setting-qvac-rag'),
settingQvacIdle: $('setting-qvac-idle'),
btnQvacOpen: $('btn-qvac-open'),
settingAutoRestore: $('setting-auto-restore'),
settingReconnectMax: $('setting-reconnect-max'),
btnResetPeers: $('btn-reset-peers'),
}
/** @type {ReturnType<typeof loadSettings>} */
let settings = loadSettings()
manager.maxReconnectTries = Number(settings.reconnectMaxAttempts) || 20
applySettingsToDom(settings)
try {
localStorage.setItem(
'peardata-ui-boot',
JSON.stringify({ theme: settings.theme })
)
} catch {
// ignore
}
/** @type {Record<string, object>} */
let chartCatalog = {}
let exploreChartId = settings.defaultExplore || 'system.io'
/** @type {Map<string, { severity: string, score: number, threshold: number|null, until: number }>} */
const anomalyByChart = new Map()
const ANOMALY_HOLD_MS = 60_000
let desktopNotifyReady = false
/** @type {Record<string, number[]>} */
const series = {
cpu: [],
cpuUser: [],
cpuSystem: [],
cpuIowait: [],
ram: [],
net: [],
netTx: [],
io: [],
ioWrite: [],
load: [],
explore: [],
}
/** Per-peer CPU series for compare mode @type {Map<string, number[]>} */
const peerCpu = new Map()
/** @type {object|null} last getFleetHealth payload for Fleet card chips */
let lastFleetHealth = null
/** @type {string} */
let currentRole = Roles.viewer
const metricsDashboard = createMetricsDashboard({
els: {
root: $('charts-view'),
toc: $('metrics-toc'),
wall: $('metrics-wall'),
search: $('chart-search'),
playBtn: $('metrics-play'),
presets: $('metrics-presets'),
meta: $('metrics-meta'),
hoverReadout: $('metrics-hover'),
resetBtn: $('metrics-reset'),
dimSortBtn: $('metrics-dim-sort'),
groupSelect: $('metrics-group'),
forcePlayBtn: $('metrics-force-play'),
boardBtn: $('metrics-board'),
correlateBtn: $('metrics-correlate'),
relatedPanel: $('metrics-related'),
mcBar: $('metrics-mc-bar'),
mcMethod: $('metrics-mc-method'),
mcHint: $('metrics-mc-hint'),
mcRunBtn: $('metrics-mc-run'),
mcClearBtn: $('metrics-mc-clear'),
mcResultsPanel: $('metrics-mc-results'),
liveEl: $('metrics-live'),
liveLabel: $('metrics-live-label'),
retentionHint: $('metrics-retention-hint'),
filtersBtn: $('metrics-filters-btn'),
filtersPanel: $('metrics-filters-panel'),
filterChip: $('metrics-filter-chip'),
},
getCatalog: () => chartCatalog,
queryData: (args) => manager.request(Methods.queryData, args),
getPoints: () => seriesMax(),
isConnected: () => Boolean(manager.active?.connected),
getPrefs: () => ({
cardHeight: settings.metricsCardHeight,
dimSort: settings.metricsDimSort,
collapsed: settings.metricsCollapsed,
chartTypes: settings.metricsChartTypes,
pinned: settings.metricsPinned,
group: settings.metricsGroup,
forcePlay: settings.metricsForcePlay,
filtersOpen: settings.metricsFiltersOpen,
}),
savePrefs: (patch) => {
/** @type {Partial<typeof settings>} */
const mapped = {}
if (patch.cardHeight != null) mapped.metricsCardHeight = patch.cardHeight
if (patch.dimSort != null) mapped.metricsDimSort = patch.dimSort
if (patch.collapsed != null) mapped.metricsCollapsed = patch.collapsed
if (patch.chartTypes != null) mapped.metricsChartTypes = patch.chartTypes
if (patch.pinned != null) mapped.metricsPinned = patch.pinned
if (patch.group != null) mapped.metricsGroup = patch.group
if (patch.forcePlay != null) mapped.metricsForcePlay = patch.forcePlay
if (patch.filtersOpen != null) mapped.metricsFiltersOpen = patch.filtersOpen
if (Object.keys(mapped).length) persist(mapped)
},
getAnomaly: (chartId) => {
const a = anomalyByChart.get(chartId)
if (!a || a.until < Date.now()) return null
return { severity: a.severity, threshold: a.threshold }
},
getWeights: (args) => manager.request(Methods.getWeights, args || {}),
})
const logsView = createLogsView({
els: {
root: $('logs-view'),
sources: $('logs-sources'),
q: /** @type {HTMLInputElement|null} */ ($('logs-q')),
presets: $('logs-presets'),
priority: /** @type {HTMLSelectElement|null} */ ($('logs-priority')),
unit: /** @type {HTMLInputElement|null} */ ($('logs-unit')),
searchBtn: $('logs-search-btn'),
clearBtn: $('logs-clear-btn'),
status: $('logs-status'),
list: $('logs-list'),
empty: $('logs-empty'),
moreBtn: $('logs-more'),
followBtn: $('logs-follow'),
refreshBtn: $('logs-refresh'),
copyBtn: $('logs-copy'),
stream: $('logs-stream'),
},
queryLogs: (args) => manager.request(Methods.queryLogs, args || {}),
getRole: () => currentRole,
isConnected: () => Boolean(manager.active?.connected),
onShowChart: (chartId, ts) => {
showView('charts')
metricsDashboard.focusChartAt(chartId, ts)
},
onCorrelate: (ts) => {
showView('charts')
metricsDashboard.correlateAround(ts || Date.now(), {
method: 'anomaly-rate',
halfWindowSec: 60,
})
},
})
const processesView = createProcessesView({
els: {
root: $('processes-view'),
status: $('proc-status'),
kpis: $('proc-kpis'),
q: /** @type {HTMLInputElement|null} */ ($('proc-q')),
filter: $('proc-filters'),
sort: /** @type {HTMLSelectElement|null} */ ($('proc-sort')),
refresh: /** @type {HTMLSelectElement|null} */ ($('proc-refresh-ms')),
followBtn: $('proc-follow'),
refreshBtn: $('proc-refresh'),
treeBtn: $('proc-tree'),
exportBtn: $('proc-export'),
chartsBtn: $('proc-charts'),
table: $('proc-table'),
tbody: $('proc-tbody'),
empty: $('proc-empty'),
detail: $('proc-detail'),
sparkCpu: null,
sparkMem: null,
topCpu: $('proc-top-cpu'),
topMem: $('proc-top-mem'),
},
listProcesses: (args) => manager.request(Methods.listProcesses, args || {}),
isConnected: () => Boolean(manager.active?.connected),
onOpenCharts: (chartId) => {
showView('charts')
if (chartId) {
metricsDashboard.scrollToChart?.(chartId)
metricsDashboard.openFocus?.(chartId)
}
},
})
const dataManager = createDataManager({
$,
manager,
getRole: () => currentRole,
log: (msg) => log(msg),
})
const qvacView = createQvacView({
els: {
root: $('qvac-view'),
setup: $('qvac-setup'),
chat: $('qvac-chat'),
messages: $('qvac-messages'),
input: /** @type {HTMLTextAreaElement|null} */ ($('qvac-input')),
sendBtn: $('qvac-send'),
status: $('qvac-status'),
modelChip: $('qvac-model-chip'),
samples: $('qvac-samples'),
setupSteps: $('qvac-setup-steps'),
resetBtn: $('qvac-reset'),
unloadBtn: $('qvac-unload'),
newChatBtn: $('qvac-new-chat'),
},
manager,
getRole: () => currentRole,
isConnected: () => Boolean(manager.active?.connected),
getSettings: () => settings,
saveSettings: (patch) => {
Object.assign(settings, patch)
persist(patch)
},
getPeerLabel: () => {
const active = manager.active
if (!active) return ''
const bm = loadBookmarks().find((b) => b.publicKeyHex === active.publicKeyHex)
return bm?.alias || `${String(active.publicKeyHex).slice(0, 12)}…`
},
getCatalog: () => chartCatalog,
onOpenChart: (chartId, ts) => {
showView('charts')
if (ts) metricsDashboard.focusChartAt(chartId, ts)
else {
metricsDashboard.scrollToChart?.(chartId)
metricsDashboard.openFocus?.(chartId)
}
},
onOpenView: (view) => showView(view),
log: (msg) => log(msg),
})
function seriesMax() {
// Overview sparks follow Charts' active window when available (capped for density)
try {
const after = metricsDashboard?.getState?.()?.afterSeconds
if (after != null && Number(after) > 0) {
return Math.max(30, Math.min(300, Math.round(Number(after))))
}
} catch {
// ignore
}
return Math.max(30, Math.min(300, Number(settings.chartPoints) || 90))
}
function persist(patch) {
settings = saveSettings(patch)
applySettingsToDom(settings)
try {
localStorage.setItem(
'peardata-ui-boot',
JSON.stringify({ theme: settings.theme })
)
} catch {
// ignore
}
return settings
}
function log(line) {
if (!els.log) return
const ts = new Date().toLocaleTimeString()
els.log.textContent = `[${ts}] ${line}\n` + els.log.textContent
}
/** @type {ReturnType<typeof setInterval>|null} */
let catalogRefreshTimer = null
function startCatalogRefresh() {
if (catalogRefreshTimer) return
// Refresh often enough that Docker/cgroup name enrichment appears without a reconnect
catalogRefreshTimer = setInterval(() => {
if (!manager.active?.connected) return
populateExploreCharts().catch(() => {})
}, 10_000)
}
function stopCatalogRefresh() {
if (!catalogRefreshTimer) return
clearInterval(catalogRefreshTimer)
catalogRefreshTimer = null
}
function setOnline(online, opts = {}) {
els.status.textContent = online ? 'live' : 'offline'
els.status.classList.toggle('online', online)
els.status.classList.toggle('offline', !online)
els.btnDisconnect.disabled = !online
els.btnInvite.disabled = !online
els.btnConnect.disabled = false
if (online) startCatalogRefresh()
else if (!manager.list().some((c) => c.connected)) stopCatalogRefresh()
const hadSamples =
series.cpu.length > 0 || series.ram.length > 0 || series.net.length > 0
const showBanner = !online && (hadSamples || opts.reconnecting)
if (els.offlineBanner) {
els.offlineBanner.classList.toggle('hidden', !showBanner)
if (showBanner) {
els.offlineBanner.textContent = opts.reconnecting
? 'Agent offline — showing last-known samples. Reconnecting…'
: 'Agent offline — showing last-known samples.'
}
}
document.body.classList.toggle('is-offline', showBanner)
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function pushPoint(key, value) {
pushRing(series[key] || (series[key] = []), value, seriesMax())
}
function pushPeerCpu(peerId, value) {
const id = String(peerId).toLowerCase()
let arr = peerCpu.get(id)
if (!arr) {
arr = []
peerCpu.set(id, arr)
}
pushRing(arr, value, seriesMax())
}
function paint(canvasId, lines, opts = {}) {
drawChart($(canvasId), lines, { ...opts, maxPoints: seriesMax() })
}
const overviewFocus = getChartFocus()
/** @type {Array<{ id: string, title: string, subtitle: string, canvasId: string, lines: () => any[], opts: () => object }>} */
const OVERVIEW_FOCUS = [
{
id: 'system.cpu',
title: 'CPU',
subtitle: 'system.cpu',
canvasId: 'chart-cpu',
lines: () => overviewCpuLines().lines,
opts: () => ({ ...anomalyOptsFor('system.cpu'), ...overviewCpuLines().opts }),
},
{
id: 'system.ram',
title: 'Memory',
subtitle: 'system.ram',
canvasId: 'chart-ram',
lines: () => [{ values: series.ram, color: CHART_PALETTE.ram }],
opts: () => ({
...anomalyOptsFor(anomalyByChart.has('mem.available') ? 'mem.available' : 'system.ram'),
mode: 'area',
}),
},
{
id: 'system.net',
title: 'Network',
subtitle: 'system.net',
canvasId: 'chart-net',
lines: () =>
series.netTx.length >= 2
? [
{ values: series.net, color: CHART_PALETTE.net, label: 'rx' },
{ values: series.netTx, color: CHART_PALETTE.disk, label: 'tx' },
]
: [{ values: series.net, color: CHART_PALETTE.net, label: 'rx' }],
opts: () => ({ ...anomalyOptsFor('system.net'), mode: 'area' }),
},
{
id: 'system.io',
title: 'Disk I/O',
subtitle: 'system.io',
canvasId: 'chart-io',
lines: () =>
series.ioWrite.length >= 2
? [
{ values: series.io, color: CHART_PALETTE.disk, label: 'read' },
{ values: series.ioWrite, color: CHART_PALETTE.cpuIowait, label: 'write' },
]
: [{ values: series.io, color: CHART_PALETTE.disk, label: 'io' }],
opts: () => ({ ...anomalyOptsFor('system.io'), mode: 'area' }),
},
{
id: 'system.load',
title: 'Load',
subtitle: 'system.load',
canvasId: 'chart-load',
lines: () => [{ values: series.load, color: CHART_PALETTE.load }],
opts: () => ({ ...anomalyOptsFor('system.load'), mode: 'line' }),
},
{
id: 'explore',
title: 'Spotlight',
subtitle: () => exploreChartId,
canvasId: 'chart-explore',
lines: () => [{ values: series.explore, color: CHART_PALETTE.explore }],
opts: () => ({
...anomalyOptsFor(exploreChartId),
mode: defaultModeFromMeta(chartCatalog[exploreChartId] || { chartType: 'line' }),
}),
},
]
function overviewCpuLines() {
if (els.compareToggle?.checked && peerCpu.size > 0) {
const lines = [...peerCpu.entries()].slice(0, 5).map(([id, values], i) => ({
values,
color: CHART_PALETTE.compare[i % CHART_PALETTE.compare.length],
label: id.slice(0, 8),
fill: false,
}))
return { lines, opts: { mode: 'line' } }
}
const lines = [
{ values: series.cpuUser.length ? series.cpuUser : series.cpu, color: CHART_PALETTE.cpuUser, label: 'user', fill: false },
{ values: series.cpuSystem, color: CHART_PALETTE.cpuSystem, label: 'system', fill: false },
{ values: series.cpuIowait, color: CHART_PALETTE.cpuIowait, label: 'iowait', fill: false },
].filter((l) => l.values.length >= 2)
if (!lines.length) {
return {
lines: [{ values: series.cpu, color: CHART_PALETTE.cpuUser, label: 'used' }],
opts: { mode: 'area' },
}
}
return { lines, opts: { mode: 'stacked' } }
}
function openOverviewFocus(focusId) {
const idx = OVERVIEW_FOCUS.findIndex((p) => p.id === focusId)
if (idx < 0) return
const openAt = (i) => {
const panel = OVERVIEW_FOCUS[(i + OVERVIEW_FOCUS.length) % OVERVIEW_FOCUS.length]
const sub =
typeof panel.subtitle === 'function' ? panel.subtitle() : panel.subtitle
overviewFocus.open({
id: panel.id,
title: panel.title,
subtitle: sub,
paint: (canvas) => {
drawChart(canvas, panel.lines(), { ...panel.opts(), maxPoints: seriesMax() })
},
onPrev: () => openAt(i - 1),
onNext: () => openAt(i + 1),
actions: [
{
id: 'charts',
label: 'Open in Charts',
title: 'Jump to Charts wall',
onClick: () => {
overviewFocus.close()
const chartId = panel.id === 'explore' ? exploreChartId : panel.id
showView('charts')
metricsDashboard.scrollToChart?.(chartId)
metricsDashboard.openFocus?.(chartId)
},
},
],
})
overviewFocus.refresh()
}
openAt(idx)
}
function bindOverviewFocus() {
const panels = [
[els.panelCpu, 'system.cpu'],
[els.panelRam, 'system.ram'],
[els.panelNet, 'system.net'],
[els.panelIo, 'system.io'],
[els.panelLoad, 'system.load'],
[els.panelExplore, 'explore'],
]
for (const [panel, id] of panels) {
if (!panel || panel.dataset.focusBound) continue
panel.dataset.focusBound = '1'
panel.classList.add('chart-panel--focusable')
if (!panel.querySelector('.chart-focus-btn')) {
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'btn btn-ghost chart-focus-btn'
btn.title = 'Fullscreen'
btn.setAttribute('aria-label', 'Fullscreen')
btn.textContent = '⛶'
const header = panel.querySelector('header')
header?.appendChild(btn)
btn.addEventListener('click', (ev) => {
ev.stopPropagation()
openOverviewFocus(id)
})
}
panel.addEventListener('dblclick', (ev) => {
if (ev.target instanceof HTMLElement && ev.target.closest('select,button,a,label')) return
openOverviewFocus(id)
})
}
}
let overviewPaintRaf = 0
/** Coalesce Overview redraws into one frame to avoid spark blink. */
function scheduleRedrawAll() {
if (overviewPaintRaf) return
overviewPaintRaf = requestAnimationFrame(() => {
overviewPaintRaf = 0
redrawAll()
})
}
function panelForChart(chart) {
if (chart === 'system.cpu') return els.panelCpu
if (chart === 'mem.available' || chart === 'system.ram') return els.panelRam
if (chart === 'system.net') return els.panelNet
if (chart === 'system.io') return els.panelIo
if (chart === 'system.load') return els.panelLoad
if (chart === exploreChartId) return els.panelExplore
return null
}
function applyAnomalyHighlights() {
const now = Date.now()
for (const [chart, state] of [...anomalyByChart.entries()]) {
if (state.until < now) anomalyByChart.delete(chart)
}
for (const panel of [
els.panelCpu,
els.panelRam,
els.panelNet,
els.panelIo,
els.panelLoad,
els.panelExplore,
]) {
if (!panel) continue
panel.classList.remove('anomaly-warn', 'anomaly-crit')
}
for (const [chart, state] of anomalyByChart) {
const panel = panelForChart(chart)
if (!panel) continue
panel.classList.add(state.severity === 'critical' ? 'anomaly-crit' : 'anomaly-warn')
}
}
function trackAnomaly(ev) {
if (!ev?.chart) return
if (ev.cleared) {
anomalyByChart.delete(ev.chart)
applyAnomalyHighlights()
redrawAll()
return
}
anomalyByChart.set(ev.chart, {
severity: ev.severity || 'warning',
score: Number(ev.score) || 0,
threshold: ev.threshold != null ? Number(ev.threshold) : null,
until: Date.now() + ANOMALY_HOLD_MS,
})
applyAnomalyHighlights()
redrawAll()
}
async function ensureDesktopNotifyPermission() {
if (!('Notification' in window)) return false
if (Notification.permission === 'granted') {
desktopNotifyReady = true
return true
}
if (Notification.permission !== 'denied') {
const perm = await Notification.requestPermission()
desktopNotifyReady = perm === 'granted'
return desktopNotifyReady
}
return false
}
function notifyEnabled() {
if (els.notifyDesktop) return els.notifyDesktop.checked
if (els.settingNotify) return els.settingNotify.checked
return Boolean(settings.notifyDesktop)
}
function desktopNotifyAnomaly(ev) {
if (!notifyEnabled()) return
if (ev.cleared) return
if (!desktopNotifyReady && Notification.permission !== 'granted') return
try {
const score = ev.score != null ? ` · score ${Number(ev.score).toFixed(2)}` : ''
new Notification(`PearData ${ev.severity || 'alert'}`, {
body: `${ev.message || ev.chart}${score}`,
tag: `peardata-${ev.chart}-${ev.severity}`,
})
} catch {
// ignore
}
}
function anomalyOptsFor(chart) {
const a = anomalyByChart.get(chart)
if (!a) return {}
return { threshold: a.threshold, severity: a.severity }
}
function redrawCpu() {
const { lines, opts } = overviewCpuLines()
paint('chart-cpu', lines, { ...anomalyOptsFor('system.cpu'), ...opts })
if (els.compareToggle?.checked && peerCpu.size > 0) {
if (els.chartCpuLabel) els.chartCpuLabel.textContent = `compare · ${lines.length} peers`
} else if (els.chartCpuLabel) {
els.chartCpuLabel.textContent = 'system.cpu'
}
if (els.legendCpu) {
const next = (lines.length ? lines : [{ color: CHART_PALETTE.cpuUser, label: 'used' }])
.map(
(l) =>
`<span style="--swatch:${l.color}">${escapeHtml(l.label || '')}</span>`
)
.join('')
if (els.legendCpu.dataset.sig !== next) {
els.legendCpu.dataset.sig = next
els.legendCpu.innerHTML = next
}
}
}
function redrawAll() {
applyAnomalyHighlights()
syncOverviewKpis()
redrawCpu()
const ramChart = anomalyByChart.has('mem.available') ? 'mem.available' : 'system.ram'
paint(
'chart-ram',
[{ values: series.ram, color: CHART_PALETTE.ram }],
{ ...anomalyOptsFor(ramChart), mode: 'area' }
)
paint(
'chart-net',
series.netTx.length >= 2
? [
{ values: series.net, color: CHART_PALETTE.net },
{ values: series.netTx, color: CHART_PALETTE.disk },
]
: [{ values: series.net, color: CHART_PALETTE.net }],
{ ...anomalyOptsFor('system.net'), mode: 'area' }
)
paint(
'chart-io',
series.ioWrite.length >= 2
? [
{ values: series.io, color: CHART_PALETTE.disk },
{ values: series.ioWrite, color: CHART_PALETTE.cpuIowait },
]
: [{ values: series.io, color: CHART_PALETTE.disk }],
{ ...anomalyOptsFor('system.io'), mode: 'area' }
)
paint(
'chart-load',
[{ values: series.load, color: CHART_PALETTE.load }],
{ ...anomalyOptsFor('system.load'), mode: 'line' }
)
paint(
'chart-explore',
[{ values: series.explore, color: CHART_PALETTE.explore }],
{
...anomalyOptsFor(exploreChartId),
mode: defaultModeFromMeta(chartCatalog[exploreChartId] || { chartType: 'line' }),
}
)
// Live-update Overview focus only (Charts wall owns its own paint callback)
if (overviewFocus.isOpen()) {
const id = overviewFocus.currentId()
if (OVERVIEW_FOCUS.some((p) => p.id === id)) overviewFocus.refresh()
}
}
function updateActivePeerChip() {
const active = manager.active
if (!els.activePeerChip) return
if (!active) {
els.activePeerChip.textContent = 'no agent'
return
}
const bm = loadBookmarks().find((b) => b.publicKeyHex === active.publicKeyHex)
els.activePeerChip.textContent =
bm?.alias || `${String(active.publicKeyHex).slice(0, 12)}…`
}
function renderBookmarks() {
loadFleetView()
}
function renderPeers() {
updateActivePeerChip()
loadFleetView()
renderFleetStrip(null, manager.list())
}
function loadFleetView() {
if (!els.fleetCards) return
const roster = buildFleetRoster({
saved: loadBookmarks(),
live: manager.list(),
activeId: manager.active?.publicKeyHex || null,
getReconnectInfo: (id) => manager.getReconnectInfo(id),
})
enrichFleetMetrics(roster)
const summary = summarizeFleet(roster)
if (els.fleetStatLive) els.fleetStatLive.textContent = String(summary.live)
if (els.fleetStatRetry) els.fleetStatRetry.textContent = String(summary.reconnecting)
if (els.fleetStatOffline) els.fleetStatOffline.textContent = String(summary.offline)
if (els.fleetStatFailed) els.fleetStatFailed.textContent = String(summary.failed)
renderFleetCards(els.fleetCards, roster, {
onConnect: () => showView('connect'),
onActivate: async (peer) => {
try {
await activatePeer(peer.publicKeyHex)
log(`Active → ${peer.alias || peer.id.slice(0, 12)}`)
loadFleetView()
} catch (err) {
log(`Switch failed: ${err.message}`)
}
},
onReconnect: async (peer) => {
try {
log(`Dialing ${peer.alias || peer.id.slice(0, 12)}…`)
await dialPeer(peer.invite || peer.publicKeyHex, {
alias: peer.alias,
invite: peer.invite,
capability: peer.capability,
adminSeed: peer.adminSeed,
})
log(`Connected ${peer.alias || peer.id.slice(0, 12)}`)
loadFleetView()
} catch (err) {
log(`Connect failed: ${err.message}`)
loadFleetView()
}
},
onForget: async (peer) => {
const label = peer.alias || peer.id.slice(0, 12)
if (!confirm(`Forget ${label}?`)) return
removeBookmark(peer.publicKeyHex)
await manager.disconnect(peer.publicKeyHex).catch(() => {})
log(`Forgot ${label}`)
loadFleetView()
renderFleetStrip(null, manager.list())
},
onOpenCharts: async (peer) => {
try {
if (peer.connected) await activatePeer(peer.publicKeyHex)
else {
await dialPeer(peer.invite || peer.publicKeyHex, {
alias: peer.alias,
invite: peer.invite,
capability: peer.capability,
adminSeed: peer.adminSeed,
})
}
showView('charts')
} catch (err) {
log(`Open charts failed: ${err.message}`)
}
},
onAlias: (peer) => {
const alias = prompt('Alias for this agent', peer.alias || '')
if (alias != null) {
setBookmarkAlias(peer.publicKeyHex, alias)
loadFleetView()
updateActivePeerChip()
}
},
})
}
/** Attach CPU/RAM/health chips from parent fleet health + active overview series. */
function enrichFleetMetrics(roster) {
/** @type {Map<string, { cpu?: number, ram?: number, health?: string }>} */
const byKey = new Map()
const children = lastFleetHealth?.children
if (Array.isArray(children)) {
for (const c of children) {
const id = String(c.publicKeyHex || '').toLowerCase()
if (!id) continue
byKey.set(id, {
cpu: c.cpu != null ? Number(c.cpu) : undefined,
ram: c.ram != null ? Number(c.ram) : undefined,
health: c.health || undefined,
})
}
}
for (const peer of roster) {
const h = byKey.get(peer.id)
if (h) {
if (h.cpu != null) peer.cpu = h.cpu
if (h.ram != null) peer.ram = h.ram
if (h.health) peer.health = h.health
}
if (peer.active && peer.connected) {
const cpu = series.cpu?.[series.cpu.length - 1]
const ram = series.ram?.[series.ram.length - 1]
if (cpu != null) peer.cpu = cpu
if (ram != null) peer.ram = ram
}
}
}
function exploreValue(chart, values) {
if (!values) return 0
if (chart === 'system.io' || chart.startsWith('disk_io.')) {
return (values.reads || values.in || 0) + (values.writes || values.out || 0)
}
if (chart.startsWith('cpu.cpu') || chart === 'system.cpu') {
return 100 - (values.idle ?? 100)
}
if (chart.startsWith('net.') || chart === 'system.net') {
return values.received ?? 0
}
if (chart.startsWith('disk_util.')) return values.utilization ?? 0
if (chart.startsWith('disk_space.')) return values.used ?? 0
if (chart === 'system.load') return values.load1 ?? 0
const first = Object.values(values).find((v) => typeof v === 'number')
return first ?? 0
}
function clearHostSeries() {
for (const key of Object.keys(series)) series[key] = []
metricsDashboard.resetData()
syncOverviewKpis()
}
/** Latest finite value in an overview series, or null. */
function lastSeriesValue(key) {
const arr = series[key]
if (!arr?.length) return null
const v = arr[arr.length - 1]
return typeof v === 'number' && Number.isFinite(v) ? v : null
}
/**
* Sync Overview KPI labels from series (history seed + live).
* Charts are filled by seedHistory; KPI text used to update only on live pushes.
*/
function syncOverviewKpis() {
const cpu = lastSeriesValue('cpu')
if (els.statCpu) els.statCpu.textContent = cpu == null ? '—' : `${cpu.toFixed(1)}%`
const ram = lastSeriesValue('ram')
if (els.statRam) els.statRam.textContent = formatMib(ram)
const load = lastSeriesValue('load')
if (els.statLoad) els.statLoad.textContent = load == null ? '—' : load.toFixed(2)
const net = lastSeriesValue('net')
if (els.statNet) els.statNet.textContent = formatKilobitsPerSec(net)
}
/** Map a queryData row to a values object keyed by dimension id. */
function rowToValues(labels, row) {
/** @type {Record<string, number|null>} */
const values = {}
if (!labels?.length || !row) return values
for (let i = 0; i < labels.length; i++) {
const name = labels[i]
if (!name || name === 'time') continue
values[name] = row[i] ?? null
}
return values
}
/**
* Switch active agent and reseed host-scoped UI.
* @param {string} publicKeyHex
*/
async function activatePeer(publicKeyHex) {
const id = String(publicKeyHex).toLowerCase()
if (!manager.setActive(id)) return false
setLastActivePeerId(id)
clearHostSeries()
setOnline(true)
renderPeers()
renderBookmarks()
updateActivePeerChip()
try {
await manager.request(Methods.subscribeMetrics, { charts: ['*'], intervalMs: 1000 })
await manager.request(Methods.subscribeAnomalies, {})
} catch {
// may already be subscribed
}
await refreshMeta().catch(() => {})
await seedHistory().catch(() => redrawAll())
return true
}
/**
* Dial a saved peer (or raw input). Activates unless skipActivate.
* @param {string} input
* @param {{ adminSeed?: string|null, alias?: string, skipActivate?: boolean, invite?: string|null, capability?: string|null }} [opts]
*/
async function dialPeer(input, opts = {}) {
const parsed = classifyConnectionInput(input)
const conn = await manager.connect(input, {
adminSeed: opts.adminSeed || null,
capability: opts.capability || null,
skipActivate: opts.skipActivate === true,
setActive: opts.skipActivate !== true,
persistActive: opts.skipActivate !== true,
autoReconnect: true,
maxReconnectTries: settings.reconnectMaxAttempts,
})
upsertPeer(
{
publicKeyHex: conn.publicKeyHex,
alias: opts.alias,
invite: opts.invite || (parsed.kind === 'invite' ? input : null),
capability: opts.capability || (parsed.kind === 'invite' ? parsed.capability : null),
adminSeed: opts.adminSeed || null,
lastConnectedAt: Date.now(),
autoConnect: true,
},
{ makeActive: opts.skipActivate !== true }
)
if (opts.skipActivate !== true) {
await activatePeer(conn.publicKeyHex)
} else {
renderPeers()
renderBookmarks()
}
return conn
}
function onSamples(samples, conn) {
const peerId = String(conn?.publicKeyHex || manager.active?.publicKeyHex || 'active').toLowerCase()
const activeId = String(manager.active?.publicKeyHex || '').toLowerCase()
const isActive = Boolean(activeId && activeId === peerId)
for (const s of samples || []) {
if (s.chart === 'system.cpu') {
const used = 100 - (s.values?.idle ?? 100)
pushPeerCpu(peerId, used)
if (isActive) {
pushPoint('cpu', used)
if (s.values?.user != null) pushPoint('cpuUser', s.values.user)
if (s.values?.system != null) pushPoint('cpuSystem', s.values.system)
if (s.values?.iowait != null) pushPoint('cpuIowait', s.values.iowait)
}
}
if (!isActive) continue
if (s.chart === 'system.ram') {
pushPoint('ram', s.values?.used ?? 0)
}
if (s.chart === 'system.load') {
pushPoint('load', s.values?.load1 ?? 0)
}
if (s.chart === 'system.net') {
const rx = s.values?.received ?? 0
const tx = s.values?.sent ?? s.values?.transmitted ?? 0
pushPoint('net', rx)
pushPoint('netTx', tx)
}
if (s.chart === 'system.io') {
pushPoint('io', s.values?.reads ?? s.values?.in ?? 0)
pushPoint('ioWrite', s.values?.writes ?? s.values?.out ?? 0)
}
if (s.chart === exploreChartId) {
pushPoint('explore', exploreValue(exploreChartId, s.values))
}
}
// Metrics wall only ingests the active agent
if (isActive) metricsDashboard.onSamples(samples || [])
scheduleRedrawAll()
}
function prependAnomaly(ev) {
if (!els.anomalyList) return
const li = document.createElement('li')
li.className = ev.severity === 'critical' ? 'crit' : ev.cleared ? 'ok' : 'warn'
const score =
ev.score != null && !ev.cleared
? ` <span class="score">${Number(ev.score).toFixed(2)}</span>`
: ''
li.innerHTML = `<strong>${escapeHtml(ev.severity || 'event')}</strong>${score} ${escapeHtml(ev.message || '')}
<span class="muted">${new Date(ev.ts || Date.now()).toLocaleTimeString()}</span>
${ev.chart ? `<span class="fleet-actions anomaly-actions">
<button type="button" class="linkish" data-act="focus">Show</button>
<button type="button" class="linkish" data-act="correlate">Correlate</button>
</span>` : ''}`
if (ev.chart) {
li.querySelector('[data-act="focus"]')?.addEventListener('click', (e) => {
e.stopPropagation()
showView('charts')
metricsDashboard.focusChartAt(ev.chart, ev.ts)
if ([...els.exploreChart.options].some((o) => o.value === ev.chart)) {
els.exploreChart.value = ev.chart
exploreChartId = ev.chart
series.explore = []
seedHistory().catch(() => redrawAll())
}
})
li.querySelector('[data-act="correlate"]')?.addEventListener('click', (e) => {
e.stopPropagation()
showView('charts')
metricsDashboard.correlateAround(ev.ts || Date.now(), {
method: 'anomaly-rate',
halfWindowSec: 60,
})
})
}
els.anomalyList.prepend(li)
while (els.anomalyList.children.length > 40) els.anomalyList.lastChild.remove()
}
function renderFleetStrip(fleet, desktopPeers) {
if (!els.fleetStrip || !els.fleetChildren) return
/** @type {Array<{ id: string, label: string, status: string, detail: string }>} */
const rows = []
if (fleet?.enabled && Array.isArray(fleet.children)) {
for (const c of fleet.children) {
const status = c.connected
? c.health === 'critical'
? 'critical'
: c.health === 'degraded'
? 'degraded'
: 'ok'
: 'offline'
const cpu = c.cpu != null ? `${Number(c.cpu).toFixed(0)}%` : '—'
const ram = c.ram != null ? `${Number(c.ram).toFixed(0)} MiB` : '—'
rows.push({
id: c.shortId || String(c.publicKeyHex || '').slice(0, 12),
label: c.hostname || c.shortId || 'child',
status,
detail: `${cpu} · ${ram}`,
})
}
const s = fleet.summary || {}
els.fleetSummary.textContent = s.status
? `${s.connected ?? 0}/${s.configured ?? rows.length} · ${s.status}${
s.avgCpu != null ? ` · avg CPU ${Number(s.avgCpu).toFixed(0)}%` : ''
}`
: `${rows.length} children`
} else {
const saved = loadBookmarks()
const byKey = new Map((desktopPeers || []).map((p) => [p.publicKeyHex, p]))
const keys = new Set([
...saved.map((b) => b.publicKeyHex),
...(desktopPeers || []).map((p) => p.publicKeyHex),
])
for (const key of keys) {
if (!key) continue
const bm = saved.find((b) => b.publicKeyHex === key)
const live = byKey.get(key)
const active = manager.active?.publicKeyHex === key
rows.push({
id: key.slice(0, 12),
publicKeyHex: key,
label: bm?.alias || `${key.slice(0, 12)}…`,
status: live?.connected ? (active ? 'ok' : 'ok') : 'offline',
detail: live?.connected ? (active ? 'active' : 'connected') : 'saved',
active,
online: Boolean(live?.connected),
})
}
if (rows.length) {
els.fleetSummary.textContent = `${rows.filter((r) => r.online).length}/${rows.length} agents`
}
}
els.fleetChildren.innerHTML = ''
for (const row of rows) {
const li = document.createElement('li')
li.dataset.status = row.status
if (row.active) li.classList.add('active')
li.innerHTML = `<strong>${escapeHtml(row.label)}</strong>
<span class="fleet-id">${escapeHtml(row.id)}</span>
<span class="fleet-metrics">${escapeHtml(row.detail)}</span>
${
row.publicKeyHex
? `<span class="fleet-actions">
<button type="button" class="linkish" data-act="activate">${row.online ? (row.active ? 'active' : 'set active') : 'connect'}</button>
</span>`
: ''
}`
const btn = li.querySelector('[data-act="activate"]')
if (btn && row.publicKeyHex) {
btn.addEventListener('click', async (e) => {
e.stopPropagation()
try {
if (row.online) {
await activatePeer(row.publicKeyHex)
log(`Active → ${row.label}`)
} else {
const bm = loadBookmarks().find((b) => b.publicKeyHex === row.publicKeyHex)
await dialPeer(bm?.invite || row.publicKeyHex, {
alias: bm?.alias,
invite: bm?.invite,
capability: bm?.capability,
adminSeed: bm?.adminSeed,
})
log(`Connected ${row.label}`)
}
showView('overview')
renderFleetStrip(null, manager.list())
} catch (err) {
log(`Fleet action failed: ${err.message}`)
}
})
}
els.fleetChildren.appendChild(li)
}
els.fleetStrip.classList.toggle('hidden', rows.length === 0)
}
function renderChartCatalog(_filter = '') {
metricsDashboard.setCatalog(chartCatalog)
}
async function refreshMeta() {
const [info, auth, health, node, fleet] = await Promise.all([
manager.request(Methods.getServerInfo, {}),
manager.request(Methods.getAuthStatus, {}),
manager.request(Methods.getHealth, {}),
manager.request(Methods.getNodeInfo, {}),
manager.request(Methods.getFleetHealth, {}).catch(() => ({ enabled: false })),
])
lastFleetHealth = fleet || null
if (els.fleetCards) loadFleetView()
if (els.serverInfo) {
els.serverInfo.textContent = JSON.stringify({ info, node, health, fleet }, null, 2)
}
currentRole = auth.role || Roles.viewer
els.roleBadge.textContent = currentRole || '—'
els.statHealth.textContent = health.status || '—'
if (els.statHealth?.parentElement) {
els.statHealth.parentElement.dataset.health = health.status || ''
}
const id = getClientIdentity()
els.connMeta.textContent = `you ${id.publicKeyHex.slice(0, 12)}… · ${auth.role} · ${auth.authMode}`
logsView.syncSourceUi()
dataManager.syncGate()
renderPeers()
renderFleetStrip(fleet, manager.list())
await populateExploreCharts()
}
async function populateExploreCharts() {
try {
const res = await manager.request(Methods.listCharts, {})
chartCatalog = res.charts || {}
renderChartCatalog(els.chartSearch?.value || '')
metricsDashboard.refreshRetention?.()
const ids = Object.keys(chartCatalog).sort()
const prefer = ids.filter(
(id) =>
id.startsWith('cpu.cpu') ||
id.startsWith('disk_io.') ||
id.startsWith('disk_ops.') ||
id.startsWith('net.') ||
id.startsWith('disk_space.') ||
id.startsWith('docker.') ||
id.startsWith('processes.') ||
id.startsWith('fleet.') ||
id.startsWith('nginx.') ||
id.startsWith('redis.') ||
id.startsWith('postgres.') ||
id.startsWith('peardock.') ||
id.startsWith('cgroup.') ||
id.startsWith('sensors.') ||
id.startsWith('disk_await.') ||
id.startsWith('mem.') ||
id.startsWith('ip.tcp') ||
id === 'system.io' ||
id === 'mem.available' ||
id === 'system.load' ||
id === 'system.softnet_stat'
)
const options = prefer.length ? prefer : ids.slice(0, 40)
const prev = exploreChartId
els.exploreChart.innerHTML = ''
for (const id of options) {
const opt = document.createElement('option')
opt.value = id
opt.textContent = chartOptionLabel(id, chartCatalog[id] || {})
els.exploreChart.appendChild(opt)
}
if (options.includes(prev)) {
els.exploreChart.value = prev
exploreChartId = prev
} else if (options.length) {
exploreChartId = options[0]
els.exploreChart.value = exploreChartId
}
} catch {
// ignore
}
}
async function seedHistory() {
const charts = [
['system.cpu', 'cpu'],
['system.ram', 'ram'],
['system.net', 'net'],
['system.io', 'io'],
['system.load', 'load'],
[exploreChartId, 'explore'],
]
const max = seriesMax()
for (const [chart, key] of charts) {
try {
const q = await manager.request(Methods.queryData, {
chart,
after: -max,
points: max,
})
series[key] = []
if (key === 'cpu') {
series.cpuUser = []
series.cpuSystem = []
series.cpuIowait = []
}
if (key === 'net') series.netTx = []
if (key === 'io') series.ioWrite = []
for (const row of q.data || []) {
// Spotlight must always land in series.explore, even when chart is system.io/cpu/…
if (key === 'explore') {
pushPoint('explore', exploreValue(chart, rowToValues(q.labels, row)))
continue
}
if (chart === 'system.cpu') {
const idleIdx = q.labels?.indexOf('idle') ?? -1
const userIdx = q.labels?.indexOf('user') ?? -1
const sysIdx = q.labels?.indexOf('system') ?? -1
const ioIdx = q.labels?.indexOf('iowait') ?? -1
const idle = idleIdx >= 0 ? row[idleIdx] : 100
pushPoint('cpu', 100 - (idle ?? 100))
if (userIdx >= 0) pushPoint('cpuUser', row[userIdx] ?? 0)
if (sysIdx >= 0) pushPoint('cpuSystem', row[sysIdx] ?? 0)
if (ioIdx >= 0) pushPoint('cpuIowait', row[ioIdx] ?? 0)
} else if (chart === 'system.ram') {
const usedIdx = q.labels?.indexOf('used') ?? -1
pushPoint('ram', usedIdx >= 0 ? row[usedIdx] ?? 0 : row[2] ?? 0)
} else if (chart === 'system.net') {
const rxIdx = q.labels?.indexOf('received') ?? 1
const txIdx = q.labels?.indexOf('sent') ?? q.labels?.indexOf('transmitted') ?? -1
pushPoint('net', row[rxIdx] ?? row[1] ?? 0)
if (txIdx >= 0) pushPoint('netTx', row[txIdx] ?? 0)
} else if (chart === 'system.io') {
const rIdx = q.labels?.indexOf('reads') ?? q.labels?.indexOf('in') ?? 1
const wIdx = q.labels?.indexOf('writes') ?? q.labels?.indexOf('out') ?? -1
pushPoint('io', row[rIdx] ?? row[1] ?? 0)
if (wIdx >= 0) pushPoint('ioWrite', row[wIdx] ?? 0)
} else if (chart === 'system.load') {
const idx = q.labels?.indexOf('load1') ?? 1
pushPoint('load', row[idx] ?? row[1] ?? 0)
} else {
pushPoint(key, row[1] ?? 0)
}
}
if (q.source === 'hyperdb-warm') log(`History ${chart} from HyperDB warm`)
} catch {
// ignore
}
}
redrawAll()
}
/* ─── Navigation ─── */
function showView(name) {
const prevLogs = !$('logs-view')?.classList.contains('hidden')
const prevProc = !$('processes-view')?.classList.contains('hidden')
document.querySelectorAll('.view').forEach((el) => el.classList.add('hidden'))
const view = $(`${name}-view`)
if (view) view.classList.remove('hidden')
document.querySelectorAll('#main-nav .nav-link').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.view === name)
})
if (prevLogs && name !== 'logs') logsView.leave?.()
if (prevProc && name !== 'processes') processesView.leave?.()
if (name === 'charts') {
metricsDashboard.render()
requestAnimationFrame(() => metricsDashboard.redrawVisible())
}
if (name === 'logs') logsView.enter()
if (name === 'processes') processesView.enter()
if (name === 'qvac') qvacView.enter()
else qvacView.leave?.()
if (name === 'fleet') loadFleetView()
if (name === 'settings') {
syncSettingsUi()
const activeTab = document.querySelector('#settings-tabs .settings-tab.active')
if (activeTab?.dataset.settingsTab === 'data') dataManager.enter()
}
requestAnimationFrame(() => redrawAll())
}
function syncSettingsUi() {
document.querySelectorAll('[data-theme-set]').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.themeSet === settings.theme)
})
document.querySelectorAll('[data-density-set]').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.densitySet === settings.density)
})
document.querySelectorAll('#accent-swatches .swatch').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.accent === settings.accent)
})
if (els.settingReduceMotion) els.settingReduceMotion.checked = settings.reduceMotion
if (els.settingChartPoints) els.settingChartPoints.value = String(settings.chartPoints)
if (els.settingDefaultExplore) els.settingDefaultExplore.value = settings.defaultExplore
if (els.settingNotify) els.settingNotify.checked = settings.notifyDesktop
if (els.notifyDesktop) els.notifyDesktop.checked = settings.notifyDesktop
if (els.compareToggle) els.compareToggle.checked = settings.comparePeers
if (els.settingAutoRestore) els.settingAutoRestore.checked = settings.autoRestorePeers !== false
if (els.settingReconnectMax) {
els.settingReconnectMax.value = String(settings.reconnectMaxAttempts ?? 20)
}
if (els.settingQvacProfile) {
els.settingQvacProfile.value = settings.qvacProfile || 'recommended'
}
if (els.settingQvacRag) els.settingQvacRag.checked = settings.qvacRag !== false
if (els.settingQvacIdle) {
els.settingQvacIdle.value = String(settings.qvacIdleUnloadMin ?? 30)
}
if (els.collapseSidebarBtn) {
els.collapseSidebarBtn.textContent = settings.sidebarCollapsed ? '' : ''
}
}
document.querySelectorAll('#main-nav .nav-link').forEach((btn) => {
btn.addEventListener('click', () => showView(btn.dataset.view))
})
document.querySelectorAll('#settings-tabs .settings-tab').forEach((tab) => {
tab.addEventListener('click', () => {
document.querySelectorAll('#settings-tabs .settings-tab').forEach((t) => {
t.classList.toggle('active', t === tab)
})
document.querySelectorAll('.settings-panel').forEach((panel) => {
panel.classList.toggle('active', panel.dataset.settingsPanel === tab.dataset.settingsTab)
})
if (tab.dataset.settingsTab === 'data') dataManager.enter()
})
})
document.querySelectorAll('[data-theme-set]').forEach((btn) => {
btn.addEventListener('click', () => {
persist({ theme: btn.dataset.themeSet })
syncSettingsUi()
})
})
document.querySelectorAll('[data-density-set]').forEach((btn) => {
btn.addEventListener('click', () => {
persist({ density: btn.dataset.densitySet })
syncSettingsUi()
requestAnimationFrame(() => redrawAll())
})
})
document.querySelectorAll('#accent-swatches .swatch').forEach((btn) => {
btn.addEventListener('click', () => {
persist({ accent: btn.dataset.accent })
syncSettingsUi()
})
})
els.settingReduceMotion?.addEventListener('change', () => {
persist({ reduceMotion: els.settingReduceMotion.checked })
})
els.settingChartPoints?.addEventListener('change', () => {
const n = Math.max(30, Math.min(300, Number(els.settingChartPoints.value) || 90))
persist({ chartPoints: n })
els.settingChartPoints.value = String(n)
seedHistory().catch(() => redrawAll())
metricsDashboard.redrawVisible()
})
els.settingDefaultExplore?.addEventListener('change', () => {
const v = els.settingDefaultExplore.value.trim() || 'system.io'
persist({ defaultExplore: v })
})
els.settingNotify?.addEventListener('change', () => {
persist({ notifyDesktop: els.settingNotify.checked })
if (els.notifyDesktop) els.notifyDesktop.checked = els.settingNotify.checked
if (els.settingNotify.checked) ensureDesktopNotifyPermission()
})
els.settingAutoRestore?.addEventListener('change', () => {
persist({ autoRestorePeers: els.settingAutoRestore.checked })
})
els.settingReconnectMax?.addEventListener('change', () => {
const n = Math.max(0, Math.min(100, Number(els.settingReconnectMax.value) || 20))
persist({ reconnectMaxAttempts: n })
manager.maxReconnectTries = n || 20
els.settingReconnectMax.value = String(n)
})
els.settingQvacProfile?.addEventListener('change', () => {
persist({ qvacProfile: els.settingQvacProfile.value || 'recommended' })
})
els.settingQvacRag?.addEventListener('change', () => {
persist({ qvacRag: els.settingQvacRag.checked })
})
els.settingQvacIdle?.addEventListener('change', () => {
const n = Math.max(0, Math.min(240, Number(els.settingQvacIdle.value) || 0))
persist({ qvacIdleUnloadMin: n })
els.settingQvacIdle.value = String(n)
})
els.btnQvacOpen?.addEventListener('click', () => showView('qvac'))
els.btnResetPeers?.addEventListener('click', async () => {
if (!confirm('Forget all saved agents and disconnect?')) return
await manager.disconnectAll({ forget: true })
const { savePeers } = await import('./client/peerCache.js')
savePeers({}, { activePeerId: null, force: true })
renderBookmarks()
renderPeers()
renderFleetStrip(null, [])
setOnline(false)
log('Cleared saved agents')
})
els.collapseSidebarBtn?.addEventListener('click', () => {
persist({ sidebarCollapsed: !settings.sidebarCollapsed })
syncSettingsUi()
requestAnimationFrame(() => redrawAll())
})
els.btnRefreshMeta?.addEventListener('click', () => {
refreshMeta().catch((err) => log(`Refresh failed: ${err.message}`))
})
/* ─── Connect / peers ─── */
els.btnConnect.addEventListener('click', async () => {
const raw = els.connectInput.value.trim()
const adminSeed = els.adminSeed.value.trim() || null
const alias = els.peerAlias.value.trim()
if (!raw) {
log('Enter a public key or pd1 invite')
return
}
els.btnConnect.disabled = true
try {
log('Dialing…')
const parsed = classifyConnectionInput(raw)
const conn = await dialPeer(raw, {
adminSeed,
alias,
invite: parsed.kind === 'invite' ? raw : null,
})
log(`Connected ${conn.publicKeyHex.slice(0, 16)}…`)
await ensureDesktopNotifyPermission()
try {
const recent = await manager.request(Methods.listAnomalies, { limit: 20 })
for (const ev of (recent.anomalies || []).reverse()) {
prependAnomaly(ev)
if (!ev.cleared) trackAnomaly(ev)
}
} catch {
// ignore
}
showView('overview')
log('Subscribed to live metrics')
} catch (err) {
log(`Connect failed: ${err.message}`)
// Still save the peer so restore/retry works
try {
const parsed = classifyConnectionInput(raw)
if (parsed.kind === 'publicKey' || parsed.kind === 'invite') {
upsertPeer({
publicKeyHex: parsed.publicKeyHex,
alias,
invite: parsed.kind === 'invite' ? raw : null,
capability: parsed.capability || null,
adminSeed,
autoConnect: true,
})
renderBookmarks()
}
} catch {
// ignore
}
if (!manager.list().some((c) => c.connected)) setOnline(false)
} finally {
els.btnConnect.disabled = false
}
})
els.btnDisconnect.addEventListener('click', async () => {
const active = manager.active?.publicKeyHex
if (active) await manager.disconnect(active)
else await manager.disconnect()
if (!manager.list().some((c) => c.connected)) setOnline(false)
renderPeers()
log(active ? `Disconnected ${active.slice(0, 12)}…` : 'Disconnected')
})
els.btnInvite.addEventListener('click', async () => {
try {
const res = await manager.request(Methods.mintInvite, { role: 'operator' })
els.inviteOut.classList.remove('hidden')
els.inviteOut.textContent = res.invite
log('Invite minted')
} catch (err) {
log(`Invite failed: ${err.message}`)
}
})
els.compareToggle?.addEventListener('change', () => {
persist({ comparePeers: els.compareToggle.checked })
redrawCpu()
})
els.exploreChart?.addEventListener('change', async () => {
exploreChartId = els.exploreChart.value
series.explore = []
try {
await seedHistory()
} catch {
redrawAll()
}
})
els.notifyDesktop?.addEventListener('change', () => {
persist({ notifyDesktop: els.notifyDesktop.checked })
if (els.settingNotify) els.settingNotify.checked = els.notifyDesktop.checked
if (els.notifyDesktop.checked) ensureDesktopNotifyPermission()
})
manager.on('push', (ev, conn) => {
if (ev.type === Pushes.metrics) onSamples(ev.data?.samples, conn)
if (ev.type === Pushes.anomaly) {
prependAnomaly(ev.data)
trackAnomaly(ev.data)
desktopNotifyAnomaly(ev.data)
log(`Anomaly: ${ev.data?.message || ''}`)
}
if (ev.type === Pushes.health) {
els.statHealth.textContent = ev.data?.status || '—'
if (els.statHealth.parentElement) {
els.statHealth.parentElement.dataset.health = ev.data?.status || ''
}
}
})
manager.on('connected', () => {
setOnline(true)
renderPeers()
renderFleetStrip(null, manager.list())
})
manager.on('disconnected', () => {
if (!manager.list().some((c) => c.connected)) setOnline(false, { reconnecting: true })
renderPeers()
renderFleetStrip(null, manager.list())
log('Agent disconnected')
})
manager.on('reconnect-failed', ({ tries }) => {
setOnline(false, { reconnecting: true })
log(`Reconnect attempt failed (try ${tries})`)
loadFleetView()
})
manager.on('reconnect-exhausted', ({ publicKeyHex }) => {
setOnline(false, { reconnecting: false })
log(`Reconnect exhausted for ${String(publicKeyHex).slice(0, 12)}…`)
loadFleetView()
})
els.fleetRefreshBtn?.addEventListener('click', () => {
loadFleetView()
renderFleetStrip(null, manager.list())
})
async function restorePeersOnBoot() {
if (settings.autoRestorePeers === false) return
const peers = loadPeers()
const entries = Object.values(peers).filter((p) => p.autoConnect !== false)
if (!entries.length) return
const preferred = getLastActivePeerId()
els.restoringBanner?.classList.remove('hidden')
if (els.restoringBanner) {
els.restoringBanner.textContent = `Restoring ${entries.length} saved agent${entries.length === 1 ? '' : 's'}…`
}
log(`Restoring ${entries.length} saved agent(s)…`)
const results = await Promise.allSettled(
entries.map((p) =>
dialPeer(p.invite || p.publicKeyHex, {
alias: p.alias,
invite: p.invite,
capability: p.capability,
adminSeed: p.adminSeed,
skipActivate: true,
})
)
)
const ok = results.filter((r) => r.status === 'fulfilled').length
log(`Restored ${ok}/${entries.length} connections`)
let activated = false
if (preferred && manager.connections.get(preferred)?.connected) {
activated = await activatePeer(preferred)
}
if (!activated) {
const first = manager.list().find((c) => c.connected)
if (first) await activatePeer(first.publicKeyHex)
}
els.restoringBanner?.classList.add('hidden')
renderFleetStrip(null, manager.list())
if (manager.active) showView('overview')
}
setOnline(false)
syncSettingsUi()
bindOverviewFocus()
showView(loadBookmarks().length ? 'fleet' : 'connect')
renderBookmarks()
renderPeers()
renderFleetStrip(null, manager.list())
log('PearData ready — connect an agent or pick a saved peer')
restorePeersOnBoot().catch((err) => {
els.restoringBanner?.classList.add('hidden')
log(`Restore failed: ${err.message}`)
})
window.addEventListener('resize', () => redrawAll())
window.addEventListener('beforeunload', () => {
manager.disconnectAll({ forget: false }).catch(() => {})
})