1826 lines
60 KiB
JavaScript
1826 lines
60 KiB
JavaScript
/**
|
||
* 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<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 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<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, '&')
|
||
.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) =>
|
||
`<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 === '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(() => {})
|
||
})
|