Updates
This commit is contained in:
@@ -10,6 +10,12 @@ import {
|
||||
removeBookmark,
|
||||
setBookmarkAlias,
|
||||
} from './client/bookmarks.js'
|
||||
import {
|
||||
loadPeers,
|
||||
upsertPeer,
|
||||
getLastActivePeerId,
|
||||
setLastActivePeerId,
|
||||
} from './client/peerCache.js'
|
||||
import { classifyConnectionInput } from './shared/crypto-auth.js'
|
||||
import {
|
||||
loadSettings,
|
||||
@@ -44,6 +50,7 @@ const els = {
|
||||
inviteOut: $('invite-out'),
|
||||
anomalyList: $('anomaly-list'),
|
||||
offlineBanner: $('offline-banner'),
|
||||
restoringBanner: $('restoring-banner'),
|
||||
fleetStrip: $('fleet-strip'),
|
||||
fleetSummary: $('fleet-summary'),
|
||||
fleetChildren: $('fleet-children'),
|
||||
@@ -76,10 +83,14 @@ const els = {
|
||||
settingChartPoints: $('setting-chart-points'),
|
||||
settingDefaultExplore: $('setting-default-explore'),
|
||||
settingNotify: $('setting-notify'),
|
||||
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(
|
||||
@@ -100,11 +111,44 @@ const metricsDashboard = createMetricsDashboard({
|
||||
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'),
|
||||
relatedPanel: $('metrics-related'),
|
||||
},
|
||||
getCatalog: () => chartCatalog,
|
||||
queryData: (args) => manager.request(Methods.queryData, args),
|
||||
getPoints: () => seriesMax(),
|
||||
onSelectChart: (id) => selectCatalogChart(id),
|
||||
getPrefs: () => ({
|
||||
cardHeight: settings.metricsCardHeight,
|
||||
dimSort: settings.metricsDimSort,
|
||||
collapsed: settings.metricsCollapsed,
|
||||
chartTypes: settings.metricsChartTypes,
|
||||
pinned: settings.metricsPinned,
|
||||
group: settings.metricsGroup,
|
||||
forcePlay: settings.metricsForcePlay,
|
||||
}),
|
||||
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 (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 || {}),
|
||||
})
|
||||
|
||||
/** @type {Record<string, number[]>} */
|
||||
@@ -396,6 +440,8 @@ function updateActivePeerChip() {
|
||||
|
||||
function renderBookmarks() {
|
||||
const list = loadBookmarks()
|
||||
const live = new Set(manager.list().map((c) => c.publicKeyHex))
|
||||
const activeId = manager.active?.publicKeyHex
|
||||
els.bookmarkList.innerHTML = ''
|
||||
if (!list.length) {
|
||||
const li = document.createElement('li')
|
||||
@@ -407,28 +453,55 @@ function renderBookmarks() {
|
||||
for (const b of list) {
|
||||
const li = document.createElement('li')
|
||||
const label = b.alias || `${b.publicKeyHex.slice(0, 12)}…`
|
||||
li.innerHTML = `<span title="${escapeHtml(b.publicKeyHex)}">${escapeHtml(label)}</span>
|
||||
const online = live.has(b.publicKeyHex)
|
||||
const isActive = activeId === b.publicKeyHex
|
||||
if (isActive) li.classList.add('active')
|
||||
li.innerHTML = `<span title="${escapeHtml(b.publicKeyHex)}">${escapeHtml(label)}
|
||||
<small class="muted">${online ? (isActive ? 'active' : 'online') : 'saved'}</small></span>
|
||||
<span class="bookmark-actions">
|
||||
<button type="button" class="linkish" data-act="connect">↗</button>
|
||||
<button type="button" class="linkish" data-act="forget">×</button>
|
||||
<button type="button" class="linkish" data-act="connect" title="${online ? 'Set active' : 'Connect'}">${online ? '●' : '↗'}</button>
|
||||
<button type="button" class="linkish" data-act="forget" title="Forget">×</button>
|
||||
</span>`
|
||||
li.querySelector('[data-act="connect"]').addEventListener('click', (e) => {
|
||||
li.querySelector('[data-act="connect"]').addEventListener('click', async (e) => {
|
||||
e.stopPropagation()
|
||||
els.connectInput.value = b.invite || b.publicKeyHex
|
||||
if (b.alias) els.peerAlias.value = b.alias
|
||||
showView('connect')
|
||||
els.btnConnect.click()
|
||||
try {
|
||||
if (online) {
|
||||
await activatePeer(b.publicKeyHex)
|
||||
log(`Active → ${label}`)
|
||||
} else {
|
||||
log(`Dialing ${label}…`)
|
||||
await dialPeer(b.invite || b.publicKeyHex, {
|
||||
alias: b.alias,
|
||||
invite: b.invite,
|
||||
capability: b.capability,
|
||||
adminSeed: b.adminSeed,
|
||||
})
|
||||
log(`Connected ${label}`)
|
||||
showView('overview')
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Connect failed: ${err.message}`)
|
||||
}
|
||||
})
|
||||
li.querySelector('[data-act="forget"]').addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
removeBookmark(b.publicKeyHex)
|
||||
manager.disconnect(b.publicKeyHex).catch(() => {})
|
||||
renderBookmarks()
|
||||
renderPeers()
|
||||
log(`Forgot ${label}`)
|
||||
})
|
||||
li.addEventListener('click', () => {
|
||||
li.addEventListener('click', async () => {
|
||||
try {
|
||||
if (online) await activatePeer(b.publicKeyHex)
|
||||
else {
|
||||
els.connectInput.value = b.invite || b.publicKeyHex
|
||||
if (b.alias) els.peerAlias.value = b.alias
|
||||
showView('connect')
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Switch failed: ${err.message}`)
|
||||
}
|
||||
})
|
||||
els.bookmarkList.appendChild(li)
|
||||
}
|
||||
@@ -453,11 +526,9 @@ function renderPeers() {
|
||||
const li = document.createElement('li')
|
||||
li.className = active ? 'active' : ''
|
||||
const label = bm?.alias || `${String(id).slice(0, 12)}…`
|
||||
li.innerHTML = `<span>${escapeHtml(label)}</span><span class="muted">${p.connected ? 'live' : '…'}</span>`
|
||||
li.innerHTML = `<span>${escapeHtml(label)}</span><span class="muted">${p.connected ? (active ? 'active' : 'live') : '…'}</span>`
|
||||
li.addEventListener('click', () => {
|
||||
manager.setActive(id)
|
||||
renderPeers()
|
||||
refreshMeta().catch(() => {})
|
||||
activatePeer(id).catch((err) => log(`Switch failed: ${err.message}`))
|
||||
})
|
||||
li.addEventListener('dblclick', () => {
|
||||
const alias = prompt('Alias for this agent', bm?.alias || '')
|
||||
@@ -470,6 +541,7 @@ function renderPeers() {
|
||||
els.peerList.appendChild(li)
|
||||
}
|
||||
updateActivePeerChip()
|
||||
renderBookmarks()
|
||||
}
|
||||
|
||||
function exploreValue(chart, values) {
|
||||
@@ -490,9 +562,75 @@ function exploreValue(chart, values) {
|
||||
return first ?? 0
|
||||
}
|
||||
|
||||
function clearHostSeries() {
|
||||
for (const key of Object.keys(series)) series[key] = []
|
||||
metricsDashboard.resetData()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = conn?.publicKeyHex || manager.active?.publicKeyHex || 'active'
|
||||
const isActive = !manager.active || manager.active.publicKeyHex === peerId
|
||||
const isActive = Boolean(manager.active && manager.active.publicKeyHex === peerId)
|
||||
|
||||
for (const s of samples || []) {
|
||||
if (s.chart === 'system.cpu') {
|
||||
@@ -537,7 +675,8 @@ function onSamples(samples, conn) {
|
||||
paint('chart-detail', [{ values: series.detail, color: CHART_PALETTE.cpuUser }])
|
||||
}
|
||||
}
|
||||
metricsDashboard.onSamples(samples || [])
|
||||
// Metrics wall only ingests the active agent
|
||||
if (isActive) metricsDashboard.onSamples(samples || [])
|
||||
redrawAll()
|
||||
}
|
||||
|
||||
@@ -556,7 +695,7 @@ function prependAnomaly(ev) {
|
||||
li.title = `Show ${ev.chart}`
|
||||
li.addEventListener('click', () => {
|
||||
showView('charts')
|
||||
metricsDashboard.scrollToChart(ev.chart)
|
||||
metricsDashboard.focusChartAt(ev.chart, ev.ts)
|
||||
selectCatalogChart(ev.chart).catch(() => {})
|
||||
if ([...els.exploreChart.options].some((o) => o.value === ev.chart)) {
|
||||
els.exploreChart.value = ev.chart
|
||||
@@ -600,25 +739,73 @@ function renderFleetStrip(fleet, desktopPeers) {
|
||||
s.avgCpu != null ? ` · avg CPU ${Number(s.avgCpu).toFixed(0)}%` : ''
|
||||
}`
|
||||
: `${rows.length} children`
|
||||
} else if (desktopPeers?.length > 1) {
|
||||
for (const p of desktopPeers) {
|
||||
} 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: String(p.publicKeyHex || '').slice(0, 12),
|
||||
label: p.publicKeyHex?.slice(0, 12) || 'peer',
|
||||
status: p.connected ? 'ok' : 'offline',
|
||||
detail: p.connected ? 'connected' : 'down',
|
||||
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),
|
||||
})
|
||||
}
|
||||
els.fleetSummary.textContent = `${desktopPeers.filter((p) => p.connected).length}/${desktopPeers.length} dialed`
|
||||
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>`
|
||||
<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)
|
||||
@@ -854,6 +1041,10 @@ function syncSettingsUi() {
|
||||
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.collapseSidebarBtn) {
|
||||
els.collapseSidebarBtn.textContent = settings.sidebarCollapsed ? '›' : '‹'
|
||||
}
|
||||
@@ -919,6 +1110,29 @@ els.settingNotify?.addEventListener('change', () => {
|
||||
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.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()
|
||||
@@ -942,17 +1156,12 @@ els.btnConnect.addEventListener('click', async () => {
|
||||
try {
|
||||
log('Dialing…')
|
||||
const parsed = classifyConnectionInput(raw)
|
||||
const conn = await manager.connect(raw, { adminSeed })
|
||||
log(`Connected ${conn.publicKeyHex.slice(0, 16)}…`)
|
||||
setOnline(true)
|
||||
upsertBookmark({
|
||||
publicKeyHex: conn.publicKeyHex,
|
||||
const conn = await dialPeer(raw, {
|
||||
adminSeed,
|
||||
alias,
|
||||
invite: parsed.kind === 'invite' ? raw : null,
|
||||
})
|
||||
renderBookmarks()
|
||||
await manager.request(Methods.subscribeMetrics, { charts: ['*'], intervalMs: 1000 })
|
||||
await manager.request(Methods.subscribeAnomalies, {})
|
||||
log(`Connected ${conn.publicKeyHex.slice(0, 16)}…`)
|
||||
await ensureDesktopNotifyPermission()
|
||||
try {
|
||||
const recent = await manager.request(Methods.listAnomalies, { limit: 20 })
|
||||
@@ -963,12 +1172,27 @@ els.btnConnect.addEventListener('click', async () => {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await refreshMeta()
|
||||
await seedHistory()
|
||||
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
|
||||
@@ -1055,11 +1279,61 @@ manager.on('reconnect-exhausted', ({ publicKeyHex }) => {
|
||||
log(`Reconnect exhausted for ${String(publicKeyHex).slice(0, 12)}…`)
|
||||
})
|
||||
|
||||
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()
|
||||
showView('overview')
|
||||
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(() => {})
|
||||
})
|
||||
|
||||
+35
-99
@@ -1,36 +1,13 @@
|
||||
/**
|
||||
* Persistent peer bookmarks (pubkey / invite / alias).
|
||||
* Stored under Pear.config.storage or ~/.config/peardata/bookmarks.json.
|
||||
* Compatibility shim — bookmarks are now the peer roster in peerCache.js.
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
|
||||
const FILE_NAME = 'bookmarks.json'
|
||||
const VERSION = 1
|
||||
|
||||
function storageRoot() {
|
||||
try {
|
||||
const pear = globalThis.Pear?.config?.storage || globalThis.Pear?.app?.storage
|
||||
if (pear) return String(pear)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
const home =
|
||||
(typeof process !== 'undefined' && (process.env?.PEARDATA_HOME || process.env?.HOME)) ||
|
||||
(typeof os.homedir === 'function' ? os.homedir() : '') ||
|
||||
''
|
||||
return path.join(home, '.config', 'peardata')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function bookmarksPath() {
|
||||
const root = storageRoot()
|
||||
return root ? path.join(root, FILE_NAME) : ''
|
||||
}
|
||||
import {
|
||||
listPeersAsBookmarks,
|
||||
upsertPeer,
|
||||
setPeerAlias,
|
||||
removePeer,
|
||||
loadPeers,
|
||||
} from './peerCache.js'
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
@@ -43,73 +20,33 @@ export function bookmarksPath() {
|
||||
* }} Bookmark
|
||||
*/
|
||||
|
||||
/**
|
||||
* @returns {Bookmark[]}
|
||||
*/
|
||||
/** @returns {Bookmark[]} */
|
||||
export function loadBookmarks() {
|
||||
const file = bookmarksPath()
|
||||
if (!file) return []
|
||||
try {
|
||||
if (!fs.existsSync(file)) return []
|
||||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
const list = Array.isArray(raw?.bookmarks) ? raw.bookmarks : []
|
||||
return list
|
||||
.filter((b) => b && /^[0-9a-f]{64}$/i.test(String(b.publicKeyHex || '')))
|
||||
.map((b) => ({
|
||||
id: String(b.id || b.publicKeyHex).toLowerCase(),
|
||||
publicKeyHex: String(b.publicKeyHex).toLowerCase(),
|
||||
alias: b.alias ? String(b.alias).slice(0, 64) : '',
|
||||
invite: b.invite || null,
|
||||
lastConnectedAt: b.lastConnectedAt || null,
|
||||
createdAt: b.createdAt || Date.now(),
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
return listPeersAsBookmarks()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Bookmark[]} bookmarks
|
||||
*/
|
||||
export function saveBookmarks(bookmarks) {
|
||||
const file = bookmarksPath()
|
||||
if (!file) return false
|
||||
try {
|
||||
const dir = path.dirname(file)
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ version: VERSION, bookmarks, updatedAt: new Date().toISOString() }, null, 2),
|
||||
{ mode: 0o600 }
|
||||
)
|
||||
/** @param {Bookmark[]} _bookmarks */
|
||||
export function saveBookmarks(_bookmarks) {
|
||||
// Roster writes go through upsertPeer / removePeer
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a bookmark after a successful connect.
|
||||
* @param {{ publicKeyHex: string, alias?: string, invite?: string|null }} peer
|
||||
* @param {{ publicKeyHex: string, alias?: string, invite?: string|null, capability?: string|null, adminSeed?: string|null }} peer
|
||||
*/
|
||||
export function upsertBookmark(peer) {
|
||||
const publicKeyHex = String(peer.publicKeyHex || '').toLowerCase()
|
||||
if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) return loadBookmarks()
|
||||
const list = loadBookmarks()
|
||||
const idx = list.findIndex((b) => b.publicKeyHex === publicKeyHex)
|
||||
const next = {
|
||||
id: publicKeyHex,
|
||||
publicKeyHex,
|
||||
alias: peer.alias != null ? String(peer.alias).slice(0, 64) : list[idx]?.alias || '',
|
||||
invite: peer.invite || list[idx]?.invite || null,
|
||||
upsertPeer(
|
||||
{
|
||||
publicKeyHex: peer.publicKeyHex,
|
||||
alias: peer.alias,
|
||||
invite: peer.invite,
|
||||
capability: peer.capability,
|
||||
adminSeed: peer.adminSeed,
|
||||
lastConnectedAt: Date.now(),
|
||||
createdAt: list[idx]?.createdAt || Date.now(),
|
||||
}
|
||||
if (idx >= 0) list[idx] = next
|
||||
else list.unshift(next)
|
||||
// keep newest 50
|
||||
saveBookmarks(list.slice(0, 50))
|
||||
return list
|
||||
},
|
||||
{ makeActive: true }
|
||||
)
|
||||
return loadBookmarks()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,21 +54,20 @@ export function upsertBookmark(peer) {
|
||||
* @param {string} alias
|
||||
*/
|
||||
export function setBookmarkAlias(publicKeyHex, alias) {
|
||||
const id = String(publicKeyHex || '').toLowerCase()
|
||||
const list = loadBookmarks()
|
||||
const b = list.find((x) => x.publicKeyHex === id)
|
||||
if (!b) return list
|
||||
b.alias = String(alias || '').slice(0, 64)
|
||||
saveBookmarks(list)
|
||||
return list
|
||||
setPeerAlias(publicKeyHex, alias)
|
||||
return loadBookmarks()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} publicKeyHex
|
||||
*/
|
||||
export function removeBookmark(publicKeyHex) {
|
||||
const id = String(publicKeyHex || '').toLowerCase()
|
||||
const list = loadBookmarks().filter((b) => b.publicKeyHex !== id)
|
||||
saveBookmarks(list)
|
||||
return list
|
||||
removePeer(publicKeyHex)
|
||||
return loadBookmarks()
|
||||
}
|
||||
|
||||
export function bookmarksPath() {
|
||||
return ''
|
||||
}
|
||||
|
||||
export { loadPeers }
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Atomic JSON cache under ~/.config/peardata/cache/{name}.json
|
||||
* (+ optional localStorage mirror / legacy migration).
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { getPeardataCacheDir } from './paths.js'
|
||||
|
||||
export const JSON_CACHE_VERSION = 1
|
||||
export const JSON_CACHE_LS_PREFIX = 'peardata.cache.'
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
export function sanitizeCacheName(name) {
|
||||
const s = String(name || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
if (!s || s === '.' || s === '..') throw new Error(`Invalid cache name: ${name}`)
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
export function getCacheFilePath(name) {
|
||||
return path.join(getPeardataCacheDir(), `${sanitizeCacheName(name)}.json`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
export function getCacheLocalStorageKey(name) {
|
||||
return JSON_CACHE_LS_PREFIX + sanitizeCacheName(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} file
|
||||
* @param {object} payload
|
||||
*/
|
||||
export function atomicWriteJson(file, payload) {
|
||||
const dir = path.dirname(file)
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
||||
const json = JSON.stringify(payload, null, 2)
|
||||
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
|
||||
fs.writeFileSync(tmp, json, { mode: 0o600 })
|
||||
fs.renameSync(tmp, file)
|
||||
try {
|
||||
fs.chmodSync(file, 0o600)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {unknown} data
|
||||
* @param {{ mirrorLocalStorage?: boolean }} [opts]
|
||||
*/
|
||||
export function saveJsonCache(name, data, opts = {}) {
|
||||
const file = getCacheFilePath(name)
|
||||
const payload = {
|
||||
version: JSON_CACHE_VERSION,
|
||||
updatedAt: new Date().toISOString(),
|
||||
data,
|
||||
}
|
||||
try {
|
||||
atomicWriteJson(file, payload)
|
||||
} catch (err) {
|
||||
console.warn('[WARN] jsonCache: write failed', name, err?.message || err)
|
||||
}
|
||||
if (opts.mirrorLocalStorage !== false) {
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(getCacheLocalStorageKey(name), JSON.stringify(payload))
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {{ legacyLocalStorageKey?: string, defaultValue?: unknown }} [opts]
|
||||
*/
|
||||
export function loadJsonCache(name, opts = {}) {
|
||||
const file = getCacheFilePath(name)
|
||||
try {
|
||||
if (fs.existsSync(file)) {
|
||||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
if (raw && typeof raw === 'object' && 'data' in raw) return raw.data
|
||||
return raw
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
// Legacy localStorage migration
|
||||
if (opts.legacyLocalStorageKey) {
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const legacy = localStorage.getItem(opts.legacyLocalStorageKey)
|
||||
if (legacy) {
|
||||
const parsed = JSON.parse(legacy)
|
||||
const data =
|
||||
parsed && typeof parsed === 'object' && 'data' in parsed ? parsed.data : parsed
|
||||
saveJsonCache(name, data ?? opts.defaultValue ?? null)
|
||||
return data ?? opts.defaultValue ?? null
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const mir = localStorage.getItem(getCacheLocalStorageKey(name))
|
||||
if (mir) {
|
||||
const parsed = JSON.parse(mir)
|
||||
return parsed && typeof parsed === 'object' && 'data' in parsed ? parsed.data : parsed
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return opts.defaultValue ?? null
|
||||
}
|
||||
+95
-12
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Multi-connection manager with reconnect + active selection.
|
||||
* PearDock-style: many peers live; one active drives RPC.
|
||||
*/
|
||||
import { EventEmitter } from 'events'
|
||||
import { PearDataConnection } from './connection.js'
|
||||
import { classifyConnectionInput } from '../shared/crypto-auth.js'
|
||||
import { setLastActivePeerId } from './peerCache.js'
|
||||
|
||||
export class ConnectionManager extends EventEmitter {
|
||||
constructor() {
|
||||
@@ -14,6 +16,8 @@ export class ConnectionManager extends EventEmitter {
|
||||
this.active = null
|
||||
/** @type {Map<string, ReturnType<typeof setTimeout>>} */
|
||||
this._reconnectTimers = new Map()
|
||||
/** @type {Map<string, object>} */
|
||||
this._reconnectOpts = new Map()
|
||||
this.maxReconnectTries =
|
||||
Number(
|
||||
(typeof process !== 'undefined' && process.env?.PEARDATA_MAX_RECONNECT) || 20
|
||||
@@ -24,7 +28,15 @@ export class ConnectionManager extends EventEmitter {
|
||||
|
||||
/**
|
||||
* @param {string} input - public key, pd1 invite, or capability+key object fields
|
||||
* @param {{ adminSeed?: string, alias?: string, autoReconnect?: boolean }} [opts]
|
||||
* @param {{
|
||||
* adminSeed?: string,
|
||||
* alias?: string,
|
||||
* autoReconnect?: boolean,
|
||||
* capability?: string,
|
||||
* skipActivate?: boolean,
|
||||
* setActive?: boolean,
|
||||
* persistActive?: boolean,
|
||||
* }} [opts]
|
||||
*/
|
||||
async connect(input, opts = {}) {
|
||||
const parsed = typeof input === 'string' ? classifyConnectionInput(input) : input
|
||||
@@ -44,16 +56,40 @@ export class ConnectionManager extends EventEmitter {
|
||||
}
|
||||
|
||||
publicKeyHex = String(publicKeyHex).toLowerCase()
|
||||
await this.disconnect(publicKeyHex)
|
||||
|
||||
const conn = new PearDataConnection(publicKeyHex, {
|
||||
// Already connected — optionally activate
|
||||
const existing = this.connections.get(publicKeyHex)
|
||||
if (existing?.connected) {
|
||||
const shouldActivate =
|
||||
opts.skipActivate !== true && opts.setActive !== false
|
||||
if (shouldActivate) this.setActive(publicKeyHex, { persist: opts.persistActive !== false })
|
||||
return existing
|
||||
}
|
||||
|
||||
await this.disconnect(publicKeyHex, { forgetReconnect: false })
|
||||
|
||||
const connOpts = {
|
||||
capability,
|
||||
adminSeed: opts.adminSeed || null,
|
||||
})
|
||||
}
|
||||
const conn = new PearDataConnection(publicKeyHex, connOpts)
|
||||
|
||||
const reconnectOpts = {
|
||||
...opts,
|
||||
capability,
|
||||
adminSeed: opts.adminSeed || null,
|
||||
autoReconnect: opts.autoReconnect !== false,
|
||||
skipActivate: true, // never steal active on reconnect
|
||||
setActive: false,
|
||||
persistActive: false,
|
||||
}
|
||||
this._reconnectOpts.set(publicKeyHex, reconnectOpts)
|
||||
|
||||
conn.on('disconnected', () => {
|
||||
this.emit('disconnected', conn)
|
||||
if (opts.autoReconnect !== false) this._scheduleReconnect(publicKeyHex, opts)
|
||||
if (reconnectOpts.autoReconnect !== false) {
|
||||
this._scheduleReconnect(publicKeyHex, reconnectOpts)
|
||||
}
|
||||
})
|
||||
conn.on('push', (ev) => this.emit('push', ev, conn))
|
||||
conn.on('error', (err) => this.emit('error', err, conn))
|
||||
@@ -61,18 +97,36 @@ export class ConnectionManager extends EventEmitter {
|
||||
await conn.connect()
|
||||
this.connections.set(publicKeyHex, conn)
|
||||
this._tries.set(publicKeyHex, 0)
|
||||
this.setActive(publicKeyHex)
|
||||
|
||||
// Never auto-activate when skipActivate (boot restore dials many peers first).
|
||||
const shouldActivate =
|
||||
opts.skipActivate !== true && opts.setActive !== false
|
||||
if (shouldActivate) {
|
||||
this.setActive(publicKeyHex, {
|
||||
persist: opts.persistActive !== false,
|
||||
})
|
||||
}
|
||||
|
||||
this.emit('connected', conn)
|
||||
return conn
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} publicKeyHex
|
||||
* @param {{ persist?: boolean }} [opts]
|
||||
*/
|
||||
setActive(publicKeyHex) {
|
||||
const conn = this.connections.get(String(publicKeyHex).toLowerCase())
|
||||
setActive(publicKeyHex, opts = {}) {
|
||||
const id = String(publicKeyHex).toLowerCase()
|
||||
const conn = this.connections.get(id)
|
||||
if (!conn) return false
|
||||
this.active = conn
|
||||
if (opts.persist !== false) {
|
||||
try {
|
||||
setLastActivePeerId(id)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
this.emit('active', conn)
|
||||
return true
|
||||
}
|
||||
@@ -96,10 +150,13 @@ export class ConnectionManager extends EventEmitter {
|
||||
|
||||
/**
|
||||
* @param {string} [publicKeyHex]
|
||||
* @param {{ forgetReconnect?: boolean }} [opts]
|
||||
*/
|
||||
async disconnect(publicKeyHex) {
|
||||
async disconnect(publicKeyHex, opts = {}) {
|
||||
if (!publicKeyHex) {
|
||||
for (const id of [...this.connections.keys()]) await this.disconnect(id)
|
||||
for (const id of [...this.connections.keys()]) {
|
||||
await this.disconnect(id, opts)
|
||||
}
|
||||
return
|
||||
}
|
||||
const id = String(publicKeyHex).toLowerCase()
|
||||
@@ -108,6 +165,10 @@ export class ConnectionManager extends EventEmitter {
|
||||
clearTimeout(timer)
|
||||
this._reconnectTimers.delete(id)
|
||||
}
|
||||
if (opts.forgetReconnect !== false) {
|
||||
this._reconnectOpts.delete(id)
|
||||
this._tries.delete(id)
|
||||
}
|
||||
const conn = this.connections.get(id)
|
||||
if (conn) {
|
||||
this.connections.delete(id)
|
||||
@@ -116,12 +177,25 @@ export class ConnectionManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close sockets but keep reconnect opts / roster for next boot.
|
||||
*/
|
||||
async disconnectAll({ forget = false } = {}) {
|
||||
for (const id of [...this.connections.keys()]) {
|
||||
await this.disconnect(id, { forgetReconnect: forget })
|
||||
}
|
||||
}
|
||||
|
||||
_scheduleReconnect(publicKeyHex, opts) {
|
||||
const id = String(publicKeyHex).toLowerCase()
|
||||
if (this._reconnectTimers.has(id)) return
|
||||
const tries = (this._tries.get(id) || 0) + 1
|
||||
this._tries.set(id, tries)
|
||||
if (tries > this.maxReconnectTries) {
|
||||
const max =
|
||||
Number(opts.maxReconnectTries) > 0
|
||||
? Number(opts.maxReconnectTries)
|
||||
: this.maxReconnectTries
|
||||
if (max > 0 && tries > max) {
|
||||
this.emit('reconnect-exhausted', { publicKeyHex: id, tries })
|
||||
return
|
||||
}
|
||||
@@ -129,7 +203,16 @@ export class ConnectionManager extends EventEmitter {
|
||||
const timer = setTimeout(async () => {
|
||||
this._reconnectTimers.delete(id)
|
||||
try {
|
||||
await this.connect(id, { ...opts, autoReconnect: true })
|
||||
const wasActive = this.active?.publicKeyHex === id
|
||||
await this.connect(id, {
|
||||
...opts,
|
||||
autoReconnect: true,
|
||||
skipActivate: !wasActive,
|
||||
setActive: wasActive,
|
||||
persistActive: wasActive,
|
||||
})
|
||||
// Prefer last-active if it comes back and nothing else is active
|
||||
if (!this.active) this.setActive(id, { persist: false })
|
||||
} catch (err) {
|
||||
this.emit('reconnect-failed', { publicKeyHex: id, err, tries })
|
||||
this._scheduleReconnect(id, opts)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Unified client storage roots (PearDock-style).
|
||||
*
|
||||
* Primary: $PEARDATA_HOME || ~/.config/peardata
|
||||
* Cache: {home}/cache/
|
||||
* Override: PEARDATA_STORAGE / Pear.config.storage for runtime-only if set as home
|
||||
*/
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getPeardataHome() {
|
||||
try {
|
||||
if (typeof process !== 'undefined') {
|
||||
if (process.env?.PEARDATA_HOME) return process.env.PEARDATA_HOME
|
||||
if (process.env?.PEARDATA_STORAGE) return process.env.PEARDATA_STORAGE
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
const pear = globalThis.Pear?.config?.storage
|
||||
if (pear) return String(pear)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const home =
|
||||
(typeof process !== 'undefined' && (process.env?.HOME || process.env?.USERPROFILE)) ||
|
||||
(typeof os.homedir === 'function' ? os.homedir() : '') ||
|
||||
''
|
||||
return path.join(home, '.config', 'peardata')
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getPeardataCacheDir() {
|
||||
return path.join(getPeardataHome(), 'cache')
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Persistent multi-host peer roster (PearDock-style).
|
||||
*
|
||||
* ~/.config/peardata/cache/peers.json
|
||||
*
|
||||
* Migrates legacy bookmarks.json on first load.
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { getPeardataCacheDir, getPeardataHome } from './paths.js'
|
||||
import { atomicWriteJson } from './jsonCache.js'
|
||||
|
||||
export const PEERS_CACHE_VERSION = 1
|
||||
export const ACTIVE_PEER_LS_KEY = 'peardata_active_peer_id'
|
||||
export const PEERS_LS_KEY = 'peardata.cache.peers'
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* publicKeyHex: string,
|
||||
* alias: string,
|
||||
* invite: string|null,
|
||||
* capability: string|null,
|
||||
* adminSeed: string|null,
|
||||
* lastConnectedAt: number|null,
|
||||
* autoConnect: boolean,
|
||||
* createdAt: number,
|
||||
* }} PeerEntry
|
||||
*/
|
||||
|
||||
export function getPeersCachePath() {
|
||||
return path.join(getPeardataCacheDir(), 'peers.json')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} raw
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function normalizeAdminSeed(raw) {
|
||||
if (raw == null || raw === '') return null
|
||||
const seed = String(raw).trim().toLowerCase()
|
||||
return /^[0-9a-f]{64}$/.test(seed) ? seed : null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} value
|
||||
* @returns {PeerEntry|null}
|
||||
*/
|
||||
export function normalizePeerEntry(value) {
|
||||
if (!value) return null
|
||||
const publicKeyHex = String(value.publicKeyHex || value.id || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) return null
|
||||
let capability = value.capability || null
|
||||
let invite = value.invite || value.inviteToken || null
|
||||
if (!capability && invite && String(invite).includes('.')) {
|
||||
// pd1 invites stay as invite; HMAC grants look like a.b
|
||||
if (!String(invite).startsWith('pd1.')) {
|
||||
capability = invite
|
||||
invite = null
|
||||
}
|
||||
}
|
||||
return {
|
||||
publicKeyHex,
|
||||
alias: value.alias ? String(value.alias).slice(0, 64) : '',
|
||||
invite: invite || null,
|
||||
capability: capability || null,
|
||||
adminSeed: normalizeAdminSeed(value.adminSeed),
|
||||
lastConnectedAt: value.lastConnectedAt || null,
|
||||
autoConnect: value.autoConnect !== false,
|
||||
createdAt: value.createdAt || Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Record<string, PeerEntry>}
|
||||
*/
|
||||
export function loadPeers() {
|
||||
const file = getPeersCachePath()
|
||||
try {
|
||||
if (fs.existsSync(file)) {
|
||||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
return parsePeersPayload(raw)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[WARN] peerCache: read failed', err?.message || err)
|
||||
}
|
||||
|
||||
// Migrate legacy bookmarks.json
|
||||
const migrated = migrateBookmarks()
|
||||
if (Object.keys(migrated).length) {
|
||||
savePeers(migrated)
|
||||
return migrated
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const mir = localStorage.getItem(PEERS_LS_KEY)
|
||||
if (mir) return parsePeersPayload(JSON.parse(mir))
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} raw
|
||||
* @returns {Record<string, PeerEntry>}
|
||||
*/
|
||||
export function parsePeersPayload(raw) {
|
||||
if (!raw || typeof raw !== 'object') return {}
|
||||
const source =
|
||||
raw.peers && typeof raw.peers === 'object' && !Array.isArray(raw.peers)
|
||||
? raw.peers
|
||||
: Array.isArray(raw.bookmarks)
|
||||
? Object.fromEntries(
|
||||
raw.bookmarks.map((b) => [String(b.publicKeyHex || '').toLowerCase(), b])
|
||||
)
|
||||
: raw
|
||||
/** @type {Record<string, PeerEntry>} */
|
||||
const out = {}
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (key === 'version' || key === 'updatedAt' || key === 'peers' || key === 'activePeerId') {
|
||||
continue
|
||||
}
|
||||
const entry = normalizePeerEntry(value)
|
||||
if (!entry) continue
|
||||
out[entry.publicKeyHex] = entry
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Record<string, PeerEntry>}
|
||||
*/
|
||||
function migrateBookmarks() {
|
||||
const candidates = [
|
||||
path.join(getPeardataHome(), 'bookmarks.json'),
|
||||
path.join(getPeardataCacheDir(), 'bookmarks.json'),
|
||||
]
|
||||
for (const file of candidates) {
|
||||
try {
|
||||
if (!fs.existsSync(file)) continue
|
||||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
const list = Array.isArray(raw?.bookmarks) ? raw.bookmarks : []
|
||||
/** @type {Record<string, PeerEntry>} */
|
||||
const out = {}
|
||||
for (const b of list) {
|
||||
const entry = normalizePeerEntry(b)
|
||||
if (entry) out[entry.publicKeyHex] = entry
|
||||
}
|
||||
if (Object.keys(out).length) return out
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, PeerEntry|object>} peersMap
|
||||
* @param {{ activePeerId?: string|null }} [opts]
|
||||
*/
|
||||
export function savePeers(peersMap, opts = {}) {
|
||||
/** @type {Record<string, PeerEntry>} */
|
||||
const peers = {}
|
||||
for (const value of Object.values(peersMap || {})) {
|
||||
const entry = normalizePeerEntry(value)
|
||||
if (!entry) continue
|
||||
peers[entry.publicKeyHex] = entry
|
||||
}
|
||||
|
||||
// Wipe protection: refuse empty write unless intentional clear
|
||||
if (!Object.keys(peers).length && opts.activePeerId !== null && Object.keys(loadPeers()).length) {
|
||||
if (!opts.force) {
|
||||
console.warn('[WARN] peerCache: refused empty wipe')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
let activePeerId =
|
||||
opts.activePeerId !== undefined ? opts.activePeerId : getLastActivePeerId()
|
||||
if (activePeerId) activePeerId = String(activePeerId).toLowerCase()
|
||||
if (activePeerId && !peers[activePeerId]) activePeerId = null
|
||||
|
||||
const payload = {
|
||||
version: PEERS_CACHE_VERSION,
|
||||
updatedAt: new Date().toISOString(),
|
||||
peers,
|
||||
}
|
||||
if (activePeerId) payload.activePeerId = activePeerId
|
||||
|
||||
try {
|
||||
atomicWriteJson(getPeersCachePath(), payload)
|
||||
} catch (err) {
|
||||
console.warn('[WARN] peerCache: write failed', err?.message || err)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(PEERS_LS_KEY, JSON.stringify(payload))
|
||||
if (activePeerId) localStorage.setItem(ACTIVE_PEER_LS_KEY, activePeerId)
|
||||
else localStorage.removeItem(ACTIVE_PEER_LS_KEY)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getLastActivePeerId() {
|
||||
try {
|
||||
const file = getPeersCachePath()
|
||||
if (fs.existsSync(file)) {
|
||||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
const id = raw?.activePeerId
|
||||
if (id && /^[0-9a-f]{64}$/i.test(id)) return String(id).toLowerCase()
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const id = localStorage.getItem(ACTIVE_PEER_LS_KEY)
|
||||
if (id && /^[0-9a-f]{64}$/i.test(id)) return String(id).toLowerCase()
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|null} id
|
||||
*/
|
||||
export function setLastActivePeerId(id) {
|
||||
const next = id && /^[0-9a-f]{64}$/i.test(id) ? String(id).toLowerCase() : null
|
||||
const peers = loadPeers()
|
||||
savePeers(peers, { activePeerId: next, force: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Partial<PeerEntry> & { publicKeyHex: string }} peer
|
||||
* @param {{ makeActive?: boolean }} [opts]
|
||||
*/
|
||||
export function upsertPeer(peer, opts = {}) {
|
||||
const entry = normalizePeerEntry(peer)
|
||||
if (!entry) return loadPeers()
|
||||
const peers = loadPeers()
|
||||
const prev = peers[entry.publicKeyHex]
|
||||
peers[entry.publicKeyHex] = {
|
||||
...entry,
|
||||
alias: peer.alias != null ? entry.alias : prev?.alias || entry.alias,
|
||||
invite: entry.invite || prev?.invite || null,
|
||||
capability: entry.capability || prev?.capability || null,
|
||||
adminSeed: entry.adminSeed || prev?.adminSeed || null,
|
||||
lastConnectedAt: peer.lastConnectedAt ?? Date.now(),
|
||||
createdAt: prev?.createdAt || entry.createdAt,
|
||||
autoConnect: peer.autoConnect != null ? entry.autoConnect : prev?.autoConnect !== false,
|
||||
}
|
||||
const active =
|
||||
opts.makeActive !== false ? entry.publicKeyHex : getLastActivePeerId()
|
||||
savePeers(peers, { activePeerId: active })
|
||||
return peers
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} publicKeyHex
|
||||
* @param {string} alias
|
||||
*/
|
||||
export function setPeerAlias(publicKeyHex, alias) {
|
||||
const id = String(publicKeyHex || '').toLowerCase()
|
||||
const peers = loadPeers()
|
||||
if (!peers[id]) return peers
|
||||
peers[id].alias = String(alias || '').slice(0, 64)
|
||||
savePeers(peers)
|
||||
return peers
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} publicKeyHex
|
||||
*/
|
||||
export function removePeer(publicKeyHex) {
|
||||
const id = String(publicKeyHex || '').toLowerCase()
|
||||
const peers = loadPeers()
|
||||
delete peers[id]
|
||||
const active = getLastActivePeerId()
|
||||
savePeers(peers, {
|
||||
activePeerId: active === id ? null : active,
|
||||
force: true,
|
||||
})
|
||||
return peers
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookmark-shaped list for UI compatibility.
|
||||
* @returns {Array<PeerEntry & { id: string }>}
|
||||
*/
|
||||
export function listPeersAsBookmarks() {
|
||||
return Object.values(loadPeers())
|
||||
.map((p) => ({ ...p, id: p.publicKeyHex }))
|
||||
.sort((a, b) => (b.lastConnectedAt || 0) - (a.lastConnectedAt || 0))
|
||||
}
|
||||
+98
-31
@@ -1,13 +1,16 @@
|
||||
/**
|
||||
* Desktop UI preferences (theme, density, chart options).
|
||||
* Stored under Pear.config.storage or ~/.config/peardata/ui-settings.json.
|
||||
* Desktop UI preferences — PearDock-style cache + FOUC localStorage mirror.
|
||||
*
|
||||
* Primary: ~/.config/peardata/cache/settings.json
|
||||
* Mirror: localStorage peardata.settings.v1
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import { getPeardataCacheDir, getPeardataHome } from './paths.js'
|
||||
import { atomicWriteJson } from './jsonCache.js'
|
||||
|
||||
const FILE_NAME = 'ui-settings.json'
|
||||
const VERSION = 1
|
||||
export const SETTINGS_CACHE_VERSION = 1
|
||||
export const SETTINGS_LOCALSTORAGE_KEY = 'peardata.settings.v1'
|
||||
|
||||
/** @typedef {{
|
||||
* theme: 'dark'|'light',
|
||||
@@ -19,6 +22,15 @@ const VERSION = 1
|
||||
* chartPoints: number,
|
||||
* defaultExplore: string,
|
||||
* reduceMotion: boolean,
|
||||
* metricsCardHeight: number,
|
||||
* metricsDimSort: 'name'|'value',
|
||||
* metricsCollapsed: string[],
|
||||
* metricsChartTypes: Record<string, string>,
|
||||
* metricsPinned: string[],
|
||||
* metricsGroup: 'average'|'min'|'max'|'sum',
|
||||
* metricsForcePlay: boolean,
|
||||
* reconnectMaxAttempts: number,
|
||||
* autoRestorePeers: boolean,
|
||||
* }} UiSettings */
|
||||
|
||||
/** @returns {UiSettings} */
|
||||
@@ -33,55 +45,110 @@ export function defaultSettings() {
|
||||
chartPoints: 90,
|
||||
defaultExplore: 'system.io',
|
||||
reduceMotion: false,
|
||||
metricsCardHeight: 160,
|
||||
metricsDimSort: 'name',
|
||||
metricsCollapsed: [],
|
||||
metricsChartTypes: {},
|
||||
metricsPinned: [],
|
||||
metricsGroup: 'average',
|
||||
metricsForcePlay: false,
|
||||
reconnectMaxAttempts: 20,
|
||||
autoRestorePeers: true,
|
||||
}
|
||||
}
|
||||
|
||||
function homeDir() {
|
||||
if (typeof globalThis !== 'undefined' && globalThis.Pear?.config?.storage) {
|
||||
return globalThis.Pear.config.storage
|
||||
}
|
||||
if (process.env.PEARDATA_HOME) return process.env.PEARDATA_HOME
|
||||
if (process.env.PEARDATA_STORAGE) return process.env.PEARDATA_STORAGE
|
||||
const base =
|
||||
process.env.XDG_CONFIG_HOME ||
|
||||
(process.platform === 'darwin'
|
||||
? path.join(os.homedir(), 'Library', 'Application Support')
|
||||
: path.join(os.homedir(), '.config'))
|
||||
return path.join(base, 'peardata')
|
||||
export function getSettingsCachePath() {
|
||||
return path.join(getPeardataCacheDir(), 'settings.json')
|
||||
}
|
||||
|
||||
/** @deprecated use getSettingsCachePath */
|
||||
export function settingsPath() {
|
||||
return path.join(homeDir(), FILE_NAME)
|
||||
return getSettingsCachePath()
|
||||
}
|
||||
|
||||
function readSettingsFile(file) {
|
||||
try {
|
||||
if (!fs.existsSync(file)) return null
|
||||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
return raw?.settings || raw || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function migrateLegacySettings() {
|
||||
const candidates = [
|
||||
path.join(getPeardataHome(), 'ui-settings.json'),
|
||||
path.join(getPeardataHome(), 'cache', 'ui-settings.json'),
|
||||
]
|
||||
for (const file of candidates) {
|
||||
const s = readSettingsFile(file)
|
||||
if (s && typeof s === 'object') return s
|
||||
}
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const boot = localStorage.getItem('peardata-ui-boot')
|
||||
if (boot) {
|
||||
const parsed = JSON.parse(boot)
|
||||
if (parsed?.theme) return { theme: parsed.theme }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function loadSettings() {
|
||||
const defaults = defaultSettings()
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(settingsPath(), 'utf8'))
|
||||
return { ...defaults, ...(raw?.settings || raw || {}) }
|
||||
} catch {
|
||||
return defaults
|
||||
let fromFile = readSettingsFile(getSettingsCachePath())
|
||||
if (!fromFile) {
|
||||
fromFile = migrateLegacySettings()
|
||||
if (fromFile) {
|
||||
const merged = { ...defaults, ...fromFile }
|
||||
saveSettings(merged)
|
||||
return merged
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (!fromFile && typeof localStorage !== 'undefined') {
|
||||
const mir = localStorage.getItem(SETTINGS_LOCALSTORAGE_KEY)
|
||||
if (mir) fromFile = JSON.parse(mir)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { ...defaults, ...(fromFile || {}) }
|
||||
}
|
||||
|
||||
/** @param {Partial<UiSettings>} patch */
|
||||
export function saveSettings(patch) {
|
||||
const next = { ...loadSettings(), ...patch }
|
||||
const dir = homeDir()
|
||||
const payload = {
|
||||
version: SETTINGS_CACHE_VERSION,
|
||||
updatedAt: new Date().toISOString(),
|
||||
settings: next,
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
settingsPath(),
|
||||
JSON.stringify({ version: VERSION, settings: next, updatedAt: new Date().toISOString() }, null, 2)
|
||||
)
|
||||
atomicWriteJson(getSettingsCachePath(), payload)
|
||||
} catch {
|
||||
// ignore persistence failures in restricted runtimes
|
||||
// ignore persistence failures
|
||||
}
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(SETTINGS_LOCALSTORAGE_KEY, JSON.stringify(next))
|
||||
localStorage.setItem(
|
||||
'peardata-ui-boot',
|
||||
JSON.stringify({ theme: next.theme })
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply settings to document body/html datasets (PearDock-style).
|
||||
* @param {UiSettings} s
|
||||
*/
|
||||
export function applySettingsToDom(s) {
|
||||
|
||||
+29
-20
@@ -51,38 +51,46 @@ Do not name third-party products in code, commits, or user-facing copy.
|
||||
|
||||
### P1 — Interaction parity
|
||||
|
||||
- [ ] Pan / zoom / reset on a card (updates shared window)
|
||||
- [ ] Synced crosshair / shared hover time across visible cards
|
||||
- [ ] Dimension show/hide + sort by name / latest value
|
||||
- [ ] Chart type switch (line / area / stacked) where units allow
|
||||
- [ ] Resize card height; persist prefs
|
||||
- [ ] Section overview KPI strip (latest values) above detail charts
|
||||
- [x] Pan / zoom / reset on a card (updates shared window)
|
||||
- [x] Synced crosshair / shared hover time across visible cards
|
||||
- [x] Dimension show/hide + sort by name / latest value
|
||||
- [x] Chart type switch (line / area / stacked) where units allow
|
||||
- [x] Resize card height; persist prefs
|
||||
- [x] Section overview KPI strip (latest values) above detail charts
|
||||
|
||||
### P2 — Filters & fleet scope
|
||||
|
||||
- [ ] Search by id **or** title / context / family / plugin
|
||||
- [ ] Host/agent chip reseed on active peer switch
|
||||
- [ ] Optional group aggregation UI (`average` / `min` / `max` / `sum`)
|
||||
- [ ] Tier / resolution hint when HyperDB warm is used
|
||||
- [x] Search by id **or** title / context / family / plugin
|
||||
- [x] Host/agent chip reseed on active peer switch
|
||||
- [x] Optional group aggregation UI (`average` / `min` / `max` / `sum`)
|
||||
- [x] Tier / resolution hint when HyperDB warm is used (card status)
|
||||
|
||||
### P3 — Investigation
|
||||
|
||||
- [ ] Expand card: stats table (min/avg/max), dim table
|
||||
- [ ] Alert click → Charts wall, scroll to chart, pause at event time
|
||||
- [ ] Highlight window for “related metrics” (uses `/weights` when ready)
|
||||
- [ ] Anomaly tint / threshold line on wall cards
|
||||
- [x] Expand card: stats table (min/avg/max), dim table (click title)
|
||||
- [x] Alert click → Charts wall, scroll to chart, pause near event time
|
||||
- [x] Related metrics panel (context/family + series correlation)
|
||||
- [x] Anomaly tint / threshold line on wall cards
|
||||
|
||||
### P4 — Shell & persistence
|
||||
|
||||
- [ ] Persist pinned charts, collapsed sections, card heights
|
||||
- [ ] Custom board view reusing the same card component (later)
|
||||
- [ ] Wallboard / force-play mode (later)
|
||||
- [x] Persist collapsed sections, card heights, dim sort, chart types
|
||||
- [x] Persist pinned charts
|
||||
- [x] Board mode (pinned-only wall reusing the same cards)
|
||||
- [x] Wallboard / force-play mode
|
||||
|
||||
### P5 — Authoring model
|
||||
|
||||
- [ ] Versioned `shared/taxonomy.js` (contexts → sections)
|
||||
- [ ] New collectors register context + priority used by TOC
|
||||
- [ ] Optional CI check: new contexts appear in taxonomy or “Other”
|
||||
- [x] Versioned `shared/taxonomy.js` (contexts → sections)
|
||||
- [x] EXTENDING.md documents taxonomy placement for new charts
|
||||
- [x] Taxonomy coverage test (`test/taxonomy-coverage.test.js`)
|
||||
|
||||
### Also shipped
|
||||
|
||||
- [x] Per-chart weights via `getWeights` RPC + `/api/v1|v2|v3/weights`
|
||||
- [x] Related ranking boosted by anomaly weights
|
||||
- [x] Drag-reorder pinned charts
|
||||
- [x] Keyboard shortcuts on Charts (`Space` play, `1–5` presets, `/` search, `f` force, `b` board, `r` reset, `Esc` clear related)
|
||||
|
||||
---
|
||||
|
||||
@@ -107,6 +115,7 @@ Do not name third-party products in code, commits, or user-facing copy.
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `shared/taxonomy.js` | Section defs + `groupCatalog()` |
|
||||
| `shared/related-metrics.js` | Related-chart ranking |
|
||||
| `ui/dashboard.js` | Metrics wall controller |
|
||||
| `ui/charts.js` | Canvas paint + hover helpers |
|
||||
| `index.html` | Charts view shell (TOC + wall + time bar) |
|
||||
|
||||
@@ -152,6 +152,7 @@ Aggregate: any CRITICAL → `critical`; else any WARNING → `degraded`; else `o
|
||||
|---------|-----|------|
|
||||
| Charts | `listCharts` | `GET /api/v1/charts` |
|
||||
| Data | `queryData` | `GET /api/v3/data` |
|
||||
| Weights | `getWeights` | `GET /api/v3/weights` |
|
||||
| Contexts | `listContexts` | `GET /api/v3/contexts` |
|
||||
| Nodes | `getNodeInfo` | `GET /api/v3/nodes` |
|
||||
| Alerts | `listAlerts` | `GET /api/v3/alerts` |
|
||||
|
||||
+30
-7
@@ -132,26 +132,49 @@ Override home with `PEARDATA_HOME` if you need isolation (CI, multi-profile).
|
||||
|
||||
| Module | Responsibility |
|
||||
|--------|----------------|
|
||||
| `client/paths.js` | Unified `~/.config/peardata` home |
|
||||
| `client/jsonCache.js` | Atomic JSON writes + LS mirror |
|
||||
| `client/settings.js` | UI prefs → `cache/settings.json` |
|
||||
| `client/peerCache.js` | Multi-host roster + last-active |
|
||||
| `client/bookmarks.js` | Compat shim over peerCache |
|
||||
| `client/identity.js` | Load/create keypair on disk |
|
||||
| `client/connection.js` | Single HyperDHT + protomux-rpc session |
|
||||
| `client/manager.js` | Multi-peer map, active selection, reconnect |
|
||||
| `client/errors.js` | Unwrap / normalize RPC errors for UI |
|
||||
| `app.js` | Wire DOM to manager + protocol methods |
|
||||
|
||||
## Persistence (PearDock-style)
|
||||
|
||||
| Store | Path |
|
||||
|-------|------|
|
||||
| Settings | `~/.config/peardata/cache/settings.json` (+ `localStorage` `peardata.settings.v1` FOUC) |
|
||||
| Peers / last-active | `~/.config/peardata/cache/peers.json` |
|
||||
| Identity | `~/.config/peardata/identity.json` |
|
||||
|
||||
Override root with `PEARDATA_HOME`. Writes are atomic (`tmp` → rename, mode `0600`). Legacy `bookmarks.json` / `ui-settings.json` migrate on first load.
|
||||
|
||||
### Multi-agent
|
||||
|
||||
- Many peers can be dialed at once; **one active** owns Overview / Charts RPC.
|
||||
- Boot restores saved peers in parallel (`skipActivate`), then prefers last-active.
|
||||
- Reconnect never steals the active host unless that host was active.
|
||||
- Agents view + fleet strip: set active / connect / forget.
|
||||
- Settings → Connections: auto-restore toggle, reconnect budget, forget-all.
|
||||
|
||||
### Manager reconnect
|
||||
|
||||
- Default max tries: `PEARDATA_MAX_RECONNECT` or **20**
|
||||
- `connect(input, { adminSeed, autoReconnect })`
|
||||
- Default max tries: settings `reconnectMaxAttempts` or `PEARDATA_MAX_RECONNECT` / **20**
|
||||
- `connect(input, { adminSeed, autoReconnect, skipActivate })`
|
||||
- Input may be **64-hex public key** or **`pd1.` invite**
|
||||
|
||||
## LocalStorage keys (demo UI)
|
||||
## LocalStorage mirrors
|
||||
|
||||
| Key | Purpose |
|
||||
|-----|---------|
|
||||
| `peardata:last-connect` | Last public key / invite string |
|
||||
| `peardata:display-name` | Last display name |
|
||||
|
||||
These are demo convenience only — production apps often prefer a file under app storage.
|
||||
| `peardata.settings.v1` | Flat settings FOUC + offline mirror |
|
||||
| `peardata-ui-boot` | Theme-only early paint |
|
||||
| `peardata_active_peer_id` | Last active peer mirror |
|
||||
| `peardata.cache.peers` | Peers envelope mirror |
|
||||
|
||||
## Development tips
|
||||
|
||||
|
||||
+5
-2
@@ -5,8 +5,11 @@
|
||||
1. Define the chart in `shared/metrics.js` (`STATIC_CHART_DEFS`, or `registerChart()` for instances).
|
||||
2. Emit samples from `server/services/collector.js` (or a new collector module).
|
||||
3. Store + REST/RPC pick it up automatically via `getAllChartDefs()` / `CHART_BY_ID`.
|
||||
4. Document dimensions in [DATA-MODEL.md](./DATA-MODEL.md).
|
||||
5. Optionally add a canvas panel in `index.html` + `app.js`.
|
||||
4. Ensure the chart lands in the Charts wall TOC via `shared/taxonomy.js` (`sectionForChart` matchers). Prefer a real section over **Other**.
|
||||
5. Document dimensions in [DATA-MODEL.md](./DATA-MODEL.md).
|
||||
6. The desktop Charts tab picks it up automatically (no per-chart HTML). Overview spotlight is optional.
|
||||
|
||||
Coverage is asserted by `test/taxonomy-coverage.test.js`.
|
||||
|
||||
## Add an RPC method
|
||||
|
||||
|
||||
+2
-2
@@ -103,7 +103,7 @@ Single-agent returns one node. With `PEARDATA_PARENT=1`, `/nodes` and `/fleet` i
|
||||
| Method | Path | Notes |
|
||||
|--------|------|-------|
|
||||
| GET | `/api/v3/q?q=` | Full-text over chart ids/titles |
|
||||
| GET | `/api/v3/weights` | MVP: health-derived scores |
|
||||
| GET | `/api/v3/weights` | Per-chart anomaly weights (`?chart=&limit=`) |
|
||||
|
||||
### Alerts
|
||||
|
||||
@@ -145,7 +145,7 @@ Single-agent returns one node. With `PEARDATA_PARENT=1`, `/nodes` and `/fleet` i
|
||||
|
||||
| Area | PearData |
|
||||
|------|----------|
|
||||
| ML weights / metric correlations | Simplified health weights |
|
||||
| ML weights / metric correlations | Per-chart anomaly weights; desktop related uses corr + weights |
|
||||
| Multi-node parent streaming | Single node; parent planned |
|
||||
| Cloud POST spaces APIs | Not implemented (agent GET style only) |
|
||||
| App/plugin charts (nginx, DB, …) | System/OS charts; plugins later |
|
||||
|
||||
+15
-8
@@ -92,7 +92,7 @@ Template rebrand, protocol, collector, memory store, anomalies, REST, desktop MV
|
||||
| Anomaly scoring + UI highlight | Done (score + panel tint + threshold line) |
|
||||
| Notifications | Done (desktop + `PEARDATA_WEBHOOK_URL`) |
|
||||
| Streaming z-score / retrain job | Done (`PEARDATA_ANOMALY_MODE`, job `retrainAnomaly`) |
|
||||
| `/api/v3/weights` depth | Real metric weights |
|
||||
| `/api/v3/weights` depth | Done (per-chart anomaly weights + `getWeights` RPC) |
|
||||
|
||||
---
|
||||
|
||||
@@ -100,6 +100,7 @@ Template rebrand, protocol, collector, memory store, anomalies, REST, desktop MV
|
||||
|
||||
| Item | Notes |
|
||||
|------|--------|
|
||||
| Desktop persistence + multi-agent | Done — PearDock-style `cache/settings.json` + `cache/peers.json`, boot restore, last-active switch (`docs/DESKTOP.md`) |
|
||||
| Signed / notarized macOS clients | Beyond ad-hoc `rcodesign` |
|
||||
| Role templates | viewer / SRE / admin presets |
|
||||
| Plugin SDK | Collector + chart registration API |
|
||||
@@ -122,10 +123,16 @@ shared time, multi-dim cards, investigation). Detail checklist:
|
||||
| Global time bar (play/pause, presets) | Done |
|
||||
| Multi-dimension live + history cards | Done |
|
||||
| Lazy visible-card query / paint | Done |
|
||||
| Dim show/hide chips + basic synced hover | Done (P0) |
|
||||
| Pan/zoom / shared window from gestures | Planned (P1) |
|
||||
| Alert → scroll-to-chart | Done (pause-at-event TBD P3) |
|
||||
| Persist layout prefs / pinned charts | Planned (P4) |
|
||||
| Dim show/hide chips + basic synced hover | Done |
|
||||
| Pan/zoom/reset + shared window from gestures | Done (P1) |
|
||||
| Section KPIs, chart type, dim sort, resize | Done (P1) |
|
||||
| Alert → scroll-to-chart + pause near event | Done |
|
||||
| Persist metrics layout prefs + pins | Done |
|
||||
| Group aggregation + force-play + related | Done |
|
||||
| Board mode (pinned-only wall) | Done |
|
||||
| Drag-reorder pins + keyboard shortcuts | Done |
|
||||
| Taxonomy coverage test + EXTENDING note | Done |
|
||||
| Multi-named custom boards | Later |
|
||||
|
||||
**Phase 6 exit (P0)** — Charts tab lists every agent chart in sections; shared
|
||||
time + play/pause; multi-dim series; Overview stays the compact home grid.
|
||||
@@ -149,8 +156,8 @@ time + play/pause; multi-dim series; Overview stays the compact home grid.
|
||||
13. ~~Webhook HMAC signing~~ ✅ (`X-PearData-Signature`)
|
||||
14. ~~z-score / retrain job~~ ✅
|
||||
15. ~~REST HyperDHT tunnel~~ ✅ (`PEARDATA_REST_TUNNEL=1`)
|
||||
16. **Master metrics dashboard (Charts)** — Phase 6 P0 ← **now**
|
||||
17. Dashboard P1 — sync crosshair / pan-zoom / dim picker
|
||||
16. ~~Master metrics dashboard (Charts)~~ ✅ Phase 6 core (wall, pins, related, weights)
|
||||
17. Multi-named custom boards / Autobase parents — later
|
||||
18. Autobase multi-writer parents — HA fleet history
|
||||
19. Windows collector depth — close `/proc`-only gaps
|
||||
20. Plugin SDK polish — public collector registration API
|
||||
@@ -177,7 +184,7 @@ time + play/pause; multi-dim series; Overview stays the compact home grid.
|
||||
| M4 | Historical scrub across restart (HyperDB warm) | ✅ |
|
||||
| M5 | Container charts from Docker hosts | ✅ opt-in spike |
|
||||
| M6 | Parent peer rolling up a fleet | ✅ opt-in spike |
|
||||
| M7 | Charts tab = sectioned master metrics wall | 🔄 Phase 6 |
|
||||
| M7 | Charts tab = sectioned master metrics wall | ✅ Phase 6 core |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+42
-3
@@ -12,8 +12,10 @@
|
||||
/>
|
||||
<script>
|
||||
try {
|
||||
var s = JSON.parse(localStorage.getItem('peardata-ui-boot') || '{}')
|
||||
if (s.theme) document.documentElement.dataset.theme = s.theme
|
||||
var raw = localStorage.getItem('peardata.settings.v1') || localStorage.getItem('peardata-ui-boot') || '{}'
|
||||
var s = JSON.parse(raw)
|
||||
var theme = s.theme || (s.settings && s.settings.theme)
|
||||
if (theme) document.documentElement.dataset.theme = theme
|
||||
} catch (e) {}
|
||||
</script>
|
||||
<link rel="stylesheet" href="./ui/styles.css" />
|
||||
@@ -72,6 +74,9 @@
|
||||
<div id="offline-banner" class="offline-banner hidden" role="status">
|
||||
Agent offline — showing last-known samples. Reconnecting…
|
||||
</div>
|
||||
<div id="restoring-banner" class="offline-banner restoring-banner hidden" role="status">
|
||||
Restoring saved agents…
|
||||
</div>
|
||||
|
||||
<!-- Overview -->
|
||||
<section id="overview-view" class="view">
|
||||
@@ -174,10 +179,13 @@
|
||||
<div>
|
||||
<p class="dash-kicker">Metrics</p>
|
||||
<h1 class="dash-title">Charts</h1>
|
||||
<p class="page-subtitle">Every collected context, sectioned and live</p>
|
||||
<p class="page-subtitle">Every collected context, sectioned and live · <kbd>Space</kbd> play · <kbd>/</kbd> search · <kbd>1–5</kbd> window</p>
|
||||
</div>
|
||||
<div class="metrics-timebar" id="metrics-timebar">
|
||||
<button type="button" id="metrics-play" class="ghost" aria-pressed="true">Pause</button>
|
||||
<button type="button" id="metrics-force-play" class="ghost" aria-pressed="false" title="Keep live updating (wallboard)">Force</button>
|
||||
<button type="button" id="metrics-board" class="ghost" aria-pressed="false" title="Show only pinned charts">Board</button>
|
||||
<button type="button" id="metrics-reset" class="ghost" title="Reset to live 5m">Reset</button>
|
||||
<div id="metrics-presets" class="metrics-presets" role="group" aria-label="Time window">
|
||||
<button type="button" class="ghost" data-preset="1m">1m</button>
|
||||
<button type="button" class="ghost active" data-preset="5m">5m</button>
|
||||
@@ -185,10 +193,21 @@
|
||||
<button type="button" class="ghost" data-preset="1h">1h</button>
|
||||
<button type="button" class="ghost" data-preset="6h">6h</button>
|
||||
</div>
|
||||
<label class="metrics-group-label muted">
|
||||
Group
|
||||
<select id="metrics-group" aria-label="Downsample aggregation">
|
||||
<option value="average">average</option>
|
||||
<option value="min">min</option>
|
||||
<option value="max">max</option>
|
||||
<option value="sum">sum</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" id="metrics-dim-sort" class="ghost">Sort: name</button>
|
||||
<span id="metrics-meta" class="muted metrics-meta"></span>
|
||||
<span id="metrics-hover" class="muted metrics-hover"></span>
|
||||
</div>
|
||||
</header>
|
||||
<div id="metrics-related" class="dash-card related-panel hidden"></div>
|
||||
<div class="metrics-shell">
|
||||
<aside class="metrics-toc dash-card">
|
||||
<input id="chart-search" type="search" placeholder="Filter by id, title, family…" autocomplete="off" />
|
||||
@@ -297,6 +316,7 @@
|
||||
<div class="settings-subtabs" id="settings-tabs" role="tablist">
|
||||
<button type="button" class="settings-tab active" data-settings-tab="appearance">Appearance</button>
|
||||
<button type="button" class="settings-tab" data-settings-tab="charts">Charts</button>
|
||||
<button type="button" class="settings-tab" data-settings-tab="connections">Connections</button>
|
||||
<button type="button" class="settings-tab" data-settings-tab="notifications">Notifications</button>
|
||||
<button type="button" class="settings-tab" data-settings-tab="about">About</button>
|
||||
</div>
|
||||
@@ -343,6 +363,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-panel" data-settings-panel="connections">
|
||||
<div class="dash-card settings-section">
|
||||
<h3>Multi-agent</h3>
|
||||
<label class="check-row">
|
||||
<input type="checkbox" id="setting-auto-restore" checked />
|
||||
Restore saved agents on launch
|
||||
</label>
|
||||
<label>
|
||||
Max reconnect attempts
|
||||
<input id="setting-reconnect-max" type="number" min="0" max="100" step="1" value="20" />
|
||||
</label>
|
||||
<p class="hint">
|
||||
Saved agents live in <code>~/.config/peardata/cache/peers.json</code>.
|
||||
Last active agent is restored when online. Reconnect never steals the active host.
|
||||
</p>
|
||||
<button type="button" id="btn-reset-peers" class="ghost">Forget all saved agents</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-panel" data-settings-panel="notifications">
|
||||
<div class="dash-card settings-section">
|
||||
<h3>Desktop</h3>
|
||||
|
||||
@@ -147,6 +147,12 @@ export function registerMonitorHandlers(session) {
|
||||
})
|
||||
|
||||
session.respond('queryData', async (args) => store.query(args), { hot: true })
|
||||
session.respond('getWeights', async (args) =>
|
||||
anomalies.getWeights({
|
||||
chart: args?.chart || args?.context,
|
||||
limit: args?.limit,
|
||||
})
|
||||
)
|
||||
|
||||
session.respond('getDbInfo', async () => {
|
||||
const db = getDb()
|
||||
|
||||
+6
-10
@@ -157,17 +157,13 @@ export async function handleRest(pathname, query) {
|
||||
}
|
||||
|
||||
// ── weights / q (stubs with useful MVP behavior) ─────────
|
||||
if (path === '/api/v3/weights' || path === '/api/v2/weights') {
|
||||
const health = getAnomalyEngine().getHealth()
|
||||
return json({
|
||||
status: health.status,
|
||||
score: health.score,
|
||||
results: health.checks.map((c) => ({
|
||||
id: c.id,
|
||||
weight: c.ok ? 0 : 1,
|
||||
info: c.detail,
|
||||
})),
|
||||
if (path === '/api/v3/weights' || path === '/api/v2/weights' || path === '/api/v1/weights') {
|
||||
return json(
|
||||
getAnomalyEngine().getWeights({
|
||||
chart: query.get('chart') || query.get('context') || undefined,
|
||||
limit: Number(query.get('limit') || query.get('points') || 100) || 100,
|
||||
})
|
||||
)
|
||||
}
|
||||
if (path === '/api/v3/q' || path === '/api/v2/q') {
|
||||
const q = (query.get('q') || query.get('query') || '').toLowerCase()
|
||||
|
||||
@@ -364,6 +364,61 @@ export class AnomalyEngine extends EventEmitter {
|
||||
const score = critical ? 0.2 : warning ? 0.7 : 1
|
||||
return { status, score, checks, ts: Date.now() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-chart investigation weights for related-metrics / UI ranking.
|
||||
* Higher weight = more anomalous / interesting right now.
|
||||
* @param {{ chart?: string, limit?: number }} [opts]
|
||||
*/
|
||||
getWeights(opts = {}) {
|
||||
/** @type {Map<string, { id: string, weight: number, severity: string|null, score: number, info: string }>} */
|
||||
const byChart = new Map()
|
||||
|
||||
const bump = (chart, weight, severity, score, info) => {
|
||||
if (!chart) return
|
||||
const prev = byChart.get(chart)
|
||||
if (!prev || weight > prev.weight) {
|
||||
byChart.set(chart, {
|
||||
id: chart,
|
||||
weight,
|
||||
severity: severity || null,
|
||||
score: score ?? weight,
|
||||
info: info || chart,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const [cfgId, st] of this.status) {
|
||||
const cfg = this.configs.get(cfgId)
|
||||
if (!cfg?.chart) continue
|
||||
if (st === 'CRITICAL') bump(cfg.chart, 1, 'critical', 1, cfg.info || cfgId)
|
||||
else if (st === 'WARNING') bump(cfg.chart, 0.65, 'warning', 0.65, cfg.info || cfgId)
|
||||
else bump(cfg.chart, 0, null, 0, cfg.info || cfgId)
|
||||
}
|
||||
|
||||
for (const ev of this.recent.slice(-80)) {
|
||||
if (ev.cleared || !ev.chart) continue
|
||||
const w =
|
||||
ev.severity === 'critical'
|
||||
? Math.max(0.85, Number(ev.score) || 0.85)
|
||||
: Math.max(0.45, Number(ev.score) || 0.45)
|
||||
bump(ev.chart, w, ev.severity || null, Number(ev.score) || w, ev.message || ev.chart)
|
||||
}
|
||||
|
||||
let results = [...byChart.values()].sort((a, b) => b.weight - a.weight || a.id.localeCompare(b.id))
|
||||
if (opts.chart) {
|
||||
const seed = String(opts.chart)
|
||||
results = results.filter((r) => r.id === seed || r.weight > 0)
|
||||
}
|
||||
const limit = Math.max(1, Math.min(500, Number(opts.limit) || 100))
|
||||
const health = this.getHealth()
|
||||
return {
|
||||
status: health.status,
|
||||
score: health.score,
|
||||
results: results.slice(0, limit),
|
||||
ts: Date.now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function round2(n) {
|
||||
|
||||
@@ -60,6 +60,7 @@ export const MethodRoles = Object.freeze({
|
||||
getChart: Roles.viewer,
|
||||
queryData: Roles.viewer,
|
||||
getAllMetrics: Roles.viewer,
|
||||
getWeights: Roles.viewer,
|
||||
|
||||
// live subscription control
|
||||
subscribeMetrics: Roles.viewer,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Rank related charts for investigation (context/family + optional series correlation).
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} seedId
|
||||
* @param {Record<string, object>} catalog
|
||||
* @param {Map<string, { dims: Map<string, number[]> }>|null} [loaded]
|
||||
* @param {{ limit?: number, weights?: Array<{ id: string, weight: number }>|Record<string, number>|null }} [opts]
|
||||
* @returns {Array<{ id: string, score: number, reason: string }>}
|
||||
*/
|
||||
export function rankRelatedCharts(seedId, catalog, loaded = null, opts = {}) {
|
||||
const seed = catalog?.[seedId]
|
||||
if (!seed) return []
|
||||
const limit = opts.limit ?? 12
|
||||
/** @type {Map<string, number>} */
|
||||
const weightMap = new Map()
|
||||
if (Array.isArray(opts.weights)) {
|
||||
for (const w of opts.weights) {
|
||||
if (w?.id) weightMap.set(w.id, Number(w.weight) || 0)
|
||||
}
|
||||
} else if (opts.weights && typeof opts.weights === 'object') {
|
||||
for (const [id, w] of Object.entries(opts.weights)) weightMap.set(id, Number(w) || 0)
|
||||
}
|
||||
|
||||
/** @type {Array<{ id: string, score: number, reason: string }>} */
|
||||
const out = []
|
||||
|
||||
for (const [id, meta] of Object.entries(catalog || {})) {
|
||||
if (id === seedId) continue
|
||||
let score = 0
|
||||
/** @type {string[]} */
|
||||
const reasons = []
|
||||
|
||||
if (meta.context && seed.context) {
|
||||
if (meta.context === seed.context) {
|
||||
score += 4
|
||||
reasons.push('same context')
|
||||
} else if (sharePrefix(String(meta.context), String(seed.context))) {
|
||||
score += 2
|
||||
reasons.push('related context')
|
||||
}
|
||||
}
|
||||
if (meta.family && seed.family && meta.family === seed.family) {
|
||||
score += 2
|
||||
reasons.push('same family')
|
||||
}
|
||||
if (meta.plugin && seed.plugin && meta.plugin === seed.plugin) {
|
||||
score += 1
|
||||
reasons.push('same plugin')
|
||||
}
|
||||
if (meta.units && seed.units && meta.units === seed.units) {
|
||||
score += 0.5
|
||||
reasons.push('same units')
|
||||
}
|
||||
|
||||
const seedCard = loaded?.get(seedId)
|
||||
const other = loaded?.get(id)
|
||||
if (seedCard?.dims?.size && other?.dims?.size) {
|
||||
const corr = maxAbsCorrelation(seedCard.dims, other.dims)
|
||||
if (corr >= 0.75) {
|
||||
score += corr * 3
|
||||
reasons.push(`corr ${corr.toFixed(2)}`)
|
||||
} else if (corr >= 0.55) {
|
||||
score += corr
|
||||
reasons.push(`corr ${corr.toFixed(2)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const aw = weightMap.get(id) || 0
|
||||
if (aw > 0) {
|
||||
score += aw * 2.5
|
||||
reasons.push(`weight ${aw.toFixed(2)}`)
|
||||
}
|
||||
|
||||
if (score > 0) out.push({ id, score, reason: reasons.join(', ') })
|
||||
}
|
||||
|
||||
out.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
|
||||
return out.slice(0, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} a
|
||||
* @param {string} b
|
||||
*/
|
||||
function sharePrefix(a, b) {
|
||||
const aa = a.split('.')
|
||||
const bb = b.split('.')
|
||||
if (aa.length < 2 || bb.length < 2) return false
|
||||
return aa[0] === bb[0] && (aa[1] === bb[1] || aa.length === 1 || bb.length === 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Map<string, number[]>} a
|
||||
* @param {Map<string, number[]>} b
|
||||
*/
|
||||
function maxAbsCorrelation(a, dimsB) {
|
||||
let best = 0
|
||||
for (const seriesA of a.values()) {
|
||||
for (const seriesB of dimsB.values()) {
|
||||
const c = Math.abs(pearson(seriesA, seriesB))
|
||||
if (c > best) best = c
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number[]} x
|
||||
* @param {number[]} y
|
||||
*/
|
||||
export function pearson(x, y) {
|
||||
const n = Math.min(x.length, y.length)
|
||||
if (n < 8) return 0
|
||||
const xs = x.slice(-n)
|
||||
const ys = y.slice(-n)
|
||||
let sx = 0
|
||||
let sy = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
sx += xs[i]
|
||||
sy += ys[i]
|
||||
}
|
||||
const mx = sx / n
|
||||
const my = sy / n
|
||||
let num = 0
|
||||
let dx = 0
|
||||
let dy = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = xs[i] - mx
|
||||
const b = ys[i] - my
|
||||
num += a * b
|
||||
dx += a * a
|
||||
dy += b * b
|
||||
}
|
||||
const den = Math.sqrt(dx * dy)
|
||||
if (!den) return 0
|
||||
return num / den
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export function validateMethodArgs(method, args = {}) {
|
||||
case 'listAlerts':
|
||||
case 'listJobs':
|
||||
case 'listPeers':
|
||||
case 'getWeights':
|
||||
return { ok: true, args }
|
||||
|
||||
case 'setDisplayName': {
|
||||
|
||||
@@ -40,3 +40,31 @@ test('evaluate includes continuous score in message', (t) => {
|
||||
t.ok(fired[0].score >= 0.6)
|
||||
t.ok(String(fired[0].message).includes('score'))
|
||||
})
|
||||
|
||||
test('getWeights ranks active anomaly charts', (t) => {
|
||||
const eng = new AnomalyEngine({ cpuCount: 2 })
|
||||
eng.setConfig({
|
||||
id: 'load_high',
|
||||
chart: 'system.load',
|
||||
dimension: 'load1',
|
||||
warn: 1,
|
||||
crit: 2,
|
||||
comparator: '>',
|
||||
enabled: true,
|
||||
info: 'Load',
|
||||
})
|
||||
eng.evaluate([
|
||||
{
|
||||
chart: 'system.load',
|
||||
context: 'system.load',
|
||||
ts: Date.now(),
|
||||
values: { load1: 5 },
|
||||
},
|
||||
])
|
||||
const w = eng.getWeights({ limit: 20 })
|
||||
t.ok(w.results?.length >= 1)
|
||||
const row = w.results.find((r) => r.id === 'system.load')
|
||||
t.ok(row)
|
||||
t.ok(row.weight >= 0.65)
|
||||
t.is(row.severity, 'critical')
|
||||
})
|
||||
|
||||
@@ -7,15 +7,15 @@ import {
|
||||
upsertBookmark,
|
||||
removeBookmark,
|
||||
setBookmarkAlias,
|
||||
bookmarksPath,
|
||||
} from '../client/bookmarks.js'
|
||||
|
||||
const tmp = path.join(os.tmpdir(), `peardata-bm-${Date.now()}`)
|
||||
import { getPeersCachePath } from '../client/peerCache.js'
|
||||
|
||||
test('bookmarks upsert alias remove', (t) => {
|
||||
fs.mkdirSync(tmp, { recursive: true })
|
||||
const prev = process.env.PEARDATA_HOME
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'peardata-bm-'))
|
||||
process.env.PEARDATA_HOME = tmp
|
||||
t.ok(bookmarksPath().includes('bookmarks.json'))
|
||||
try {
|
||||
t.ok(getPeersCachePath().includes(path.join('cache', 'peers.json')))
|
||||
|
||||
const pk = 'a'.repeat(64)
|
||||
upsertBookmark({ publicKeyHex: pk, alias: 'box-1' })
|
||||
@@ -29,11 +29,13 @@ test('bookmarks upsert alias remove', (t) => {
|
||||
|
||||
removeBookmark(pk)
|
||||
t.is(loadBookmarks().length, 0)
|
||||
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.PEARDATA_HOME
|
||||
else process.env.PEARDATA_HOME = prev
|
||||
try {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
delete process.env.PEARDATA_HOME
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import test from 'brittle'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import {
|
||||
normalizePeerEntry,
|
||||
parsePeersPayload,
|
||||
loadPeers,
|
||||
savePeers,
|
||||
upsertPeer,
|
||||
removePeer,
|
||||
getLastActivePeerId,
|
||||
setLastActivePeerId,
|
||||
getPeersCachePath,
|
||||
} from '../client/peerCache.js'
|
||||
|
||||
function withHome(fn) {
|
||||
const prev = process.env.PEARDATA_HOME
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'peardata-peers-'))
|
||||
process.env.PEARDATA_HOME = tmp
|
||||
try {
|
||||
return fn(tmp)
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.PEARDATA_HOME
|
||||
else process.env.PEARDATA_HOME = prev
|
||||
try {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('normalizePeerEntry accepts hex keys', (t) => {
|
||||
const hex = 'a'.repeat(64)
|
||||
const e = normalizePeerEntry({ publicKeyHex: hex, alias: 'prod' })
|
||||
t.is(e.publicKeyHex, hex)
|
||||
t.is(e.alias, 'prod')
|
||||
t.absent(normalizePeerEntry({ publicKeyHex: 'nope' }))
|
||||
})
|
||||
|
||||
test('parsePeersPayload reads envelope + bookmarks legacy', (t) => {
|
||||
const hex = 'b'.repeat(64)
|
||||
const fromEnv = parsePeersPayload({
|
||||
version: 1,
|
||||
peers: { [hex]: { publicKeyHex: hex, alias: 'x' } },
|
||||
})
|
||||
t.ok(fromEnv[hex])
|
||||
const fromBm = parsePeersPayload({
|
||||
bookmarks: [{ publicKeyHex: hex, alias: 'y' }],
|
||||
})
|
||||
t.is(fromBm[hex].alias, 'y')
|
||||
})
|
||||
|
||||
test('upsertPeer + active id round-trip', (t) => {
|
||||
withHome(() => {
|
||||
const hex = 'c'.repeat(64)
|
||||
upsertPeer({ publicKeyHex: hex, alias: 'lab' }, { makeActive: true })
|
||||
const peers = loadPeers()
|
||||
t.is(peers[hex].alias, 'lab')
|
||||
t.is(getLastActivePeerId(), hex)
|
||||
t.ok(fs.existsSync(getPeersCachePath()))
|
||||
|
||||
setLastActivePeerId(null)
|
||||
t.absent(getLastActivePeerId())
|
||||
|
||||
removePeer(hex)
|
||||
t.absent(loadPeers()[hex])
|
||||
})
|
||||
})
|
||||
|
||||
test('savePeers refuses accidental wipe', (t) => {
|
||||
withHome(() => {
|
||||
const hex = 'd'.repeat(64)
|
||||
upsertPeer({ publicKeyHex: hex }, { makeActive: true })
|
||||
const ok = savePeers({})
|
||||
t.is(ok, false)
|
||||
t.ok(loadPeers()[hex], 'peer still present after refused wipe')
|
||||
savePeers({}, { force: true, activePeerId: null })
|
||||
t.is(Object.keys(loadPeers()).length, 0)
|
||||
})
|
||||
})
|
||||
@@ -39,6 +39,7 @@ test('method roles cover monitoring surface', (t) => {
|
||||
'unlinkPeer',
|
||||
'getFleetHealth',
|
||||
'listChildPeers',
|
||||
'getWeights',
|
||||
]) {
|
||||
t.ok(MethodRoles[m], m)
|
||||
t.is(Methods[m], m)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import test from 'brittle'
|
||||
import { pearson, rankRelatedCharts } from '../shared/related-metrics.js'
|
||||
|
||||
test('pearson correlates aligned series', (t) => {
|
||||
const x = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
const y = x.map((v) => v * 2)
|
||||
t.ok(pearson(x, y) > 0.99)
|
||||
const z = x.map((v) => -v)
|
||||
t.ok(pearson(x, z) < -0.99)
|
||||
})
|
||||
|
||||
test('rankRelatedCharts prefers same context/family', (t) => {
|
||||
const catalog = {
|
||||
'system.cpu': { context: 'system.cpu', family: 'cpu', plugin: 'proc', units: '%' },
|
||||
'cpu.cpu0': { context: 'cpu.cpu', family: 'cpu', plugin: 'proc', units: '%' },
|
||||
'system.ram': { context: 'system.ram', family: 'ram', plugin: 'proc', units: 'MiB' },
|
||||
'nginx.connections': { context: 'nginx.connections', family: 'nginx', plugin: 'nginx', units: 'connections' },
|
||||
}
|
||||
const ranked = rankRelatedCharts('system.cpu', catalog, null, { limit: 5 })
|
||||
t.ok(ranked.length >= 1)
|
||||
t.is(ranked[0].id, 'cpu.cpu0')
|
||||
t.ok(!ranked.some((r) => r.id === 'nginx.connections') || ranked.at(-1)?.id === 'nginx.connections')
|
||||
})
|
||||
|
||||
test('rankRelatedCharts boosts loaded correlation', (t) => {
|
||||
const series = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
|
||||
const catalog = {
|
||||
a: { context: 'x.a', family: 'f1', plugin: 'p' },
|
||||
b: { context: 'y.b', family: 'f2', plugin: 'q' },
|
||||
c: { context: 'z.c', family: 'f3', plugin: 'r' },
|
||||
}
|
||||
const loaded = new Map([
|
||||
['a', { dims: new Map([['v', series]]) }],
|
||||
['b', { dims: new Map([['v', series.map((n) => n * 3)]]) }],
|
||||
['c', { dims: new Map([['v', series.map((_, i) => (i % 2 ? 10 : 0))]]) }],
|
||||
])
|
||||
const ranked = rankRelatedCharts('a', catalog, loaded, { limit: 5 })
|
||||
t.ok(ranked[0].id === 'b')
|
||||
t.ok(ranked[0].reason.includes('corr'))
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import test from 'brittle'
|
||||
import { getAllChartDefs } from '../shared/metrics.js'
|
||||
import { sectionForChart, SECTIONS } from '../shared/taxonomy.js'
|
||||
|
||||
test('static catalog charts map into taxonomy sections', (t) => {
|
||||
const defs = getAllChartDefs()
|
||||
t.ok(defs.length > 20)
|
||||
/** @type {Record<string, number>} */
|
||||
const counts = Object.fromEntries(SECTIONS.map((s) => [s.id, 0]))
|
||||
let other = 0
|
||||
for (const def of defs) {
|
||||
const sec = sectionForChart(def.id, def)
|
||||
counts[sec.id] = (counts[sec.id] || 0) + 1
|
||||
if (sec.id === 'other') other++
|
||||
}
|
||||
t.ok(counts.system > 0, 'system section should have charts')
|
||||
// Most built-in charts should not dump into Other
|
||||
const ratio = other / defs.length
|
||||
t.ok(ratio < 0.35, `other ratio ${ratio.toFixed(2)} should stay under 35%`)
|
||||
})
|
||||
|
||||
test('common plugin prefixes are classified', (t) => {
|
||||
const samples = [
|
||||
['system.cpu', 'system'],
|
||||
['mem.available', 'system'],
|
||||
['docker.cpu.x', 'containers'],
|
||||
['zfs.arc', 'storage'],
|
||||
['sensors.temp.cpu', 'hardware'],
|
||||
['nginx.connections', 'applications'],
|
||||
['ebpf.cachestat', 'observability'],
|
||||
['fleet.nodes', 'fleet'],
|
||||
]
|
||||
for (const [id, want] of samples) {
|
||||
t.is(sectionForChart(id, { context: id }).id, want, id)
|
||||
}
|
||||
})
|
||||
+702
-71
File diff suppressed because it is too large
Load Diff
+264
@@ -448,6 +448,19 @@ body.sidebar-collapsed #conn-meta {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.restoring-banner {
|
||||
border-color: rgba(56, 189, 248, 0.35);
|
||||
background: rgba(56, 189, 248, 0.1);
|
||||
}
|
||||
|
||||
.fleet-children li.active {
|
||||
border-color: rgba(52, 211, 153, 0.45);
|
||||
}
|
||||
|
||||
.fleet-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.offline-banner {
|
||||
margin: calc(-1 * var(--space-lg) + 4px) calc(-1 * var(--space-lg) + 4px) var(--space) ;
|
||||
padding: 10px 16px;
|
||||
@@ -627,6 +640,15 @@ body.is-offline .offline-banner:not(.hidden) {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.fleet-children li.active {
|
||||
border-color: rgba(52, 211, 153, 0.45);
|
||||
}
|
||||
|
||||
.fleet-actions .linkish {
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.fleet-children li strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -662,6 +684,17 @@ body.is-offline .offline-banner:not(.hidden) {
|
||||
gap: 12px 20px;
|
||||
}
|
||||
|
||||
.page-subtitle kbd {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.12));
|
||||
background: var(--bg-elevated);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.metrics-timebar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -688,6 +721,107 @@ body.is-offline .offline-banner:not(.hidden) {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.metrics-group-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.metrics-group-label select {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#metrics-force-play.active,
|
||||
#metrics-board.active {
|
||||
background: rgba(52, 211, 153, 0.18);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
#charts-view.force-play .metrics-wall {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
.related-panel {
|
||||
margin-bottom: var(--space);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.related-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.related-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.related-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.related-item:hover {
|
||||
border-color: rgba(52, 211, 153, 0.4);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.metric-card.related {
|
||||
box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.45);
|
||||
}
|
||||
|
||||
.metric-card.pinned {
|
||||
border-color: rgba(251, 191, 36, 0.4);
|
||||
}
|
||||
|
||||
.metrics-pinned .metrics-section-head h2 {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.metrics-pinned .pin-hint {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.metric-card.pin-draggable {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.metric-card.dragging {
|
||||
opacity: 0.55;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.metric-pin-btn,
|
||||
.metric-related-btn {
|
||||
font-size: 12px !important;
|
||||
padding: 2px 6px !important;
|
||||
min-width: 28px;
|
||||
}
|
||||
|
||||
.metrics-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
@@ -900,6 +1034,136 @@ body.is-offline .offline-banner:not(.hidden) {
|
||||
box-shadow: 0 0 0 1px rgba(52, 211, 153, 0.55), 0 0 24px rgba(52, 211, 153, 0.18);
|
||||
}
|
||||
|
||||
.metric-card[data-severity='warning'] {
|
||||
border-color: rgba(251, 191, 36, 0.45);
|
||||
}
|
||||
.metric-card[data-severity='critical'] {
|
||||
border-color: rgba(248, 113, 113, 0.5);
|
||||
}
|
||||
|
||||
.metric-card-title {
|
||||
cursor: pointer;
|
||||
}
|
||||
.metric-card-title:hover {
|
||||
color: var(--accent, #34d399);
|
||||
}
|
||||
|
||||
.metric-card-stats {
|
||||
overflow: auto;
|
||||
max-height: 140px;
|
||||
}
|
||||
|
||||
.metric-card-stats.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dim-stats {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dim-stats th,
|
||||
.dim-stats td {
|
||||
padding: 3px 6px;
|
||||
text-align: right;
|
||||
border-bottom: 1px solid rgba(154, 168, 188, 0.12);
|
||||
}
|
||||
|
||||
.dim-stats th:first-child,
|
||||
.dim-stats td:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dim-stats th {
|
||||
color: var(--text-faint);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.metric-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.metric-type-btn {
|
||||
font-size: 10px !important;
|
||||
padding: 2px 8px !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.metric-card canvas.panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.metric-card canvas {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.metric-resize {
|
||||
height: 6px;
|
||||
margin: 2px -4px -2px;
|
||||
border-radius: 4px;
|
||||
cursor: ns-resize;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.metric-resize:hover {
|
||||
background: rgba(52, 211, 153, 0.25);
|
||||
}
|
||||
|
||||
.section-kpis {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 0 0 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.section-kpi {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
min-width: 110px;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.section-kpi:hover {
|
||||
border-color: rgba(52, 211, 153, 0.35);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section-kpi .kpi-label {
|
||||
font-size: 11px;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section-kpi .kpi-val {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section-kpi .kpi-val small {
|
||||
font-weight: 400;
|
||||
color: var(--text-faint);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.metrics-detail {
|
||||
margin-top: var(--space);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user