/** * 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 { createDashboardStore, createCustomDashboardView, } from './ui/custom-dashboard.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} */ 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} */ let chartCatalog = {} let exploreChartId = settings.defaultExplore || 'system.io' /** @type {Map} */ const anomalyByChart = new Map() const ANOMALY_HOLD_MS = 60_000 let desktopNotifyReady = false /** @type {Record} */ const series = { cpu: [], cpuUser: [], cpuSystem: [], cpuIowait: [], ram: [], net: [], netTx: [], io: [], ioWrite: [], load: [], explore: [], } /** Per-peer CPU series for compare mode @type {Map} */ 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} */ 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 dashboardStore = createDashboardStore( settings.customDashboards || [], settings.activeDashboardId || null ) const customDashboardView = createCustomDashboardView({ els: { root: $('dashboard-view'), list: $('dashboard-list'), title: $('dashboard-title'), grid: $('dashboard-grid'), empty: $('dashboard-empty'), nameInput: /** @type {HTMLInputElement|null} */ ($('dashboard-name')), descInput: /** @type {HTMLInputElement|null} */ ($('dashboard-desc')), addSelect: /** @type {HTMLSelectElement|null} */ ($('dashboard-add-select')), addBtn: $('dashboard-add-btn'), newBtn: $('dashboard-new'), deleteBtn: $('dashboard-delete'), saveBtn: $('dashboard-save'), editToggle: $('dashboard-edit'), meta: $('dashboard-meta'), }, getCatalog: () => chartCatalog, request: (m, a) => manager.request(m, a || {}), getStore: () => dashboardStore, persist: (patch) => { Object.assign(settings, patch) persist(patch) }, isConnected: () => Boolean(manager.active?.connected), 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'), settingsBtn: $('qvac-settings-btn'), settingsPanel: $('qvac-settings-panel'), agentBar: $('qvac-agent-bar'), }, 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, chartOpts = {}) => { // Tab switch is owned by tools.maybeNavigate — only prepare chart state here if (chartOpts.navigate) showView('charts') if (metricsDashboard.showCharts) { metricsDashboard.showCharts({ charts: [chartId], pin: chartOpts.pin !== false, boardOnly: chartOpts.pin !== false, focus: chartId, ts, mode: chartOpts.mode, preset: chartOpts.preset, openFocus: Boolean(chartOpts.navigate), }) return } if (ts) metricsDashboard.focusChartAt(chartId, ts) else { metricsDashboard.scrollToChart?.(chartId) if (chartOpts.navigate) metricsDashboard.openFocus?.(chartId) } }, onOpenView: (view) => showView(view), getAutoNavigate: () => settings.qvacAutoNavigate || 'ask', charts: { // Do not showView here — tools call onOpenView only when navigation is allowed showCharts: (o) => metricsDashboard.showCharts?.(o) || { ok: false, error: 'unavailable' }, setPinned: (ids, o) => metricsDashboard.setPinned?.(ids, o), setChartMode: (id, mode) => metricsDashboard.setChartMode?.(id, mode), setTimeWindow: (o) => metricsDashboard.setTimeWindow?.(o), setFilter: (q) => metricsDashboard.setFilter?.(q), showRelated: (id) => metricsDashboard.showRelated?.(id), openFocus: (id) => metricsDashboard.openFocus?.(id), scrollToChart: (id) => metricsDashboard.scrollToChart?.(id), correlateAround: (ts, o) => metricsDashboard.correlateAround?.(ts, o), runMetricCorrelations: (o) => metricsDashboard.runMetricCorrelations?.(o), }, dashboards: { list: () => customDashboardView.listDashboards(), create: (a) => customDashboardView.createDashboard(a), update: (a) => customDashboardView.updateDashboard(a), remove: (a) => customDashboardView.deleteDashboard(a), addCharts: (a) => customDashboardView.addDashboardCharts(a), removeCharts: (a) => customDashboardView.removeDashboardCharts(a), open: (a) => customDashboardView.openDashboard(a), }, 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|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, '&') .replace(//g, '>') .replace(/"/g, '"') } 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) => `${escapeHtml(l.label || '')}` ) .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} */ 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} */ 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 ? ` ${Number(ev.score).toFixed(2)}` : '' li.innerHTML = `${escapeHtml(ev.severity || 'event')}${score} ${escapeHtml(ev.message || '')} ${new Date(ev.ts || Date.now()).toLocaleTimeString()} ${ev.chart ? ` ` : ''}` 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 = `${escapeHtml(row.label)} ${escapeHtml(row.id)} ${escapeHtml(row.detail)} ${ row.publicKeyHex ? ` ` : '' }` 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 === 'dashboard') customDashboardView.enter() else customDashboardView.leave?.() 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(() => {}) })