Updates
CI / test (push) Successful in 1m1s
Release rolling / release (push) Successful in 6m59s

This commit is contained in:
Raven Scott
2026-07-18 20:32:05 -04:00
parent 41f5476a60
commit a7648dc090
28 changed files with 2539 additions and 333 deletions
+309 -35
View File
@@ -10,6 +10,12 @@ import {
removeBookmark, removeBookmark,
setBookmarkAlias, setBookmarkAlias,
} from './client/bookmarks.js' } from './client/bookmarks.js'
import {
loadPeers,
upsertPeer,
getLastActivePeerId,
setLastActivePeerId,
} from './client/peerCache.js'
import { classifyConnectionInput } from './shared/crypto-auth.js' import { classifyConnectionInput } from './shared/crypto-auth.js'
import { import {
loadSettings, loadSettings,
@@ -44,6 +50,7 @@ const els = {
inviteOut: $('invite-out'), inviteOut: $('invite-out'),
anomalyList: $('anomaly-list'), anomalyList: $('anomaly-list'),
offlineBanner: $('offline-banner'), offlineBanner: $('offline-banner'),
restoringBanner: $('restoring-banner'),
fleetStrip: $('fleet-strip'), fleetStrip: $('fleet-strip'),
fleetSummary: $('fleet-summary'), fleetSummary: $('fleet-summary'),
fleetChildren: $('fleet-children'), fleetChildren: $('fleet-children'),
@@ -76,10 +83,14 @@ const els = {
settingChartPoints: $('setting-chart-points'), settingChartPoints: $('setting-chart-points'),
settingDefaultExplore: $('setting-default-explore'), settingDefaultExplore: $('setting-default-explore'),
settingNotify: $('setting-notify'), settingNotify: $('setting-notify'),
settingAutoRestore: $('setting-auto-restore'),
settingReconnectMax: $('setting-reconnect-max'),
btnResetPeers: $('btn-reset-peers'),
} }
/** @type {ReturnType<typeof loadSettings>} */ /** @type {ReturnType<typeof loadSettings>} */
let settings = loadSettings() let settings = loadSettings()
manager.maxReconnectTries = Number(settings.reconnectMaxAttempts) || 20
applySettingsToDom(settings) applySettingsToDom(settings)
try { try {
localStorage.setItem( localStorage.setItem(
@@ -100,11 +111,44 @@ const metricsDashboard = createMetricsDashboard({
presets: $('metrics-presets'), presets: $('metrics-presets'),
meta: $('metrics-meta'), meta: $('metrics-meta'),
hoverReadout: $('metrics-hover'), 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, getCatalog: () => chartCatalog,
queryData: (args) => manager.request(Methods.queryData, args), queryData: (args) => manager.request(Methods.queryData, args),
getPoints: () => seriesMax(), getPoints: () => seriesMax(),
onSelectChart: (id) => selectCatalogChart(id), 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[]>} */ /** @type {Record<string, number[]>} */
@@ -396,6 +440,8 @@ function updateActivePeerChip() {
function renderBookmarks() { function renderBookmarks() {
const list = loadBookmarks() const list = loadBookmarks()
const live = new Set(manager.list().map((c) => c.publicKeyHex))
const activeId = manager.active?.publicKeyHex
els.bookmarkList.innerHTML = '' els.bookmarkList.innerHTML = ''
if (!list.length) { if (!list.length) {
const li = document.createElement('li') const li = document.createElement('li')
@@ -407,28 +453,55 @@ function renderBookmarks() {
for (const b of list) { for (const b of list) {
const li = document.createElement('li') const li = document.createElement('li')
const label = b.alias || `${b.publicKeyHex.slice(0, 12)}` 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"> <span class="bookmark-actions">
<button type="button" class="linkish" data-act="connect">↗</button> <button type="button" class="linkish" data-act="connect" title="${online ? 'Set active' : 'Connect'}">${online ? '●' : '↗'}</button>
<button type="button" class="linkish" data-act="forget">×</button> <button type="button" class="linkish" data-act="forget" title="Forget">×</button>
</span>` </span>`
li.querySelector('[data-act="connect"]').addEventListener('click', (e) => { li.querySelector('[data-act="connect"]').addEventListener('click', async (e) => {
e.stopPropagation() e.stopPropagation()
els.connectInput.value = b.invite || b.publicKeyHex try {
if (b.alias) els.peerAlias.value = b.alias if (online) {
showView('connect') await activatePeer(b.publicKeyHex)
els.btnConnect.click() 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) => { li.querySelector('[data-act="forget"]').addEventListener('click', (e) => {
e.stopPropagation() e.stopPropagation()
removeBookmark(b.publicKeyHex) removeBookmark(b.publicKeyHex)
manager.disconnect(b.publicKeyHex).catch(() => {})
renderBookmarks() renderBookmarks()
renderPeers()
log(`Forgot ${label}`) 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 els.connectInput.value = b.invite || b.publicKeyHex
if (b.alias) els.peerAlias.value = b.alias if (b.alias) els.peerAlias.value = b.alias
showView('connect') showView('connect')
}
} catch (err) {
log(`Switch failed: ${err.message}`)
}
}) })
els.bookmarkList.appendChild(li) els.bookmarkList.appendChild(li)
} }
@@ -453,11 +526,9 @@ function renderPeers() {
const li = document.createElement('li') const li = document.createElement('li')
li.className = active ? 'active' : '' li.className = active ? 'active' : ''
const label = bm?.alias || `${String(id).slice(0, 12)}` 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', () => { li.addEventListener('click', () => {
manager.setActive(id) activatePeer(id).catch((err) => log(`Switch failed: ${err.message}`))
renderPeers()
refreshMeta().catch(() => {})
}) })
li.addEventListener('dblclick', () => { li.addEventListener('dblclick', () => {
const alias = prompt('Alias for this agent', bm?.alias || '') const alias = prompt('Alias for this agent', bm?.alias || '')
@@ -470,6 +541,7 @@ function renderPeers() {
els.peerList.appendChild(li) els.peerList.appendChild(li)
} }
updateActivePeerChip() updateActivePeerChip()
renderBookmarks()
} }
function exploreValue(chart, values) { function exploreValue(chart, values) {
@@ -490,9 +562,75 @@ function exploreValue(chart, values) {
return first ?? 0 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) { function onSamples(samples, conn) {
const peerId = conn?.publicKeyHex || manager.active?.publicKeyHex || 'active' 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 || []) { for (const s of samples || []) {
if (s.chart === 'system.cpu') { if (s.chart === 'system.cpu') {
@@ -537,7 +675,8 @@ function onSamples(samples, conn) {
paint('chart-detail', [{ values: series.detail, color: CHART_PALETTE.cpuUser }]) 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() redrawAll()
} }
@@ -556,7 +695,7 @@ function prependAnomaly(ev) {
li.title = `Show ${ev.chart}` li.title = `Show ${ev.chart}`
li.addEventListener('click', () => { li.addEventListener('click', () => {
showView('charts') showView('charts')
metricsDashboard.scrollToChart(ev.chart) metricsDashboard.focusChartAt(ev.chart, ev.ts)
selectCatalogChart(ev.chart).catch(() => {}) selectCatalogChart(ev.chart).catch(() => {})
if ([...els.exploreChart.options].some((o) => o.value === ev.chart)) { if ([...els.exploreChart.options].some((o) => o.value === ev.chart)) {
els.exploreChart.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)}%` : '' s.avgCpu != null ? ` · avg CPU ${Number(s.avgCpu).toFixed(0)}%` : ''
}` }`
: `${rows.length} children` : `${rows.length} children`
} else if (desktopPeers?.length > 1) { } else {
for (const p of desktopPeers) { 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({ rows.push({
id: String(p.publicKeyHex || '').slice(0, 12), id: key.slice(0, 12),
label: p.publicKeyHex?.slice(0, 12) || 'peer', publicKeyHex: key,
status: p.connected ? 'ok' : 'offline', label: bm?.alias || `${key.slice(0, 12)}`,
detail: p.connected ? 'connected' : 'down', 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 = '' els.fleetChildren.innerHTML = ''
for (const row of rows) { for (const row of rows) {
const li = document.createElement('li') const li = document.createElement('li')
li.dataset.status = row.status li.dataset.status = row.status
if (row.active) li.classList.add('active')
li.innerHTML = `<strong>${escapeHtml(row.label)}</strong> li.innerHTML = `<strong>${escapeHtml(row.label)}</strong>
<span class="fleet-id">${escapeHtml(row.id)}</span> <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.fleetChildren.appendChild(li)
} }
els.fleetStrip.classList.toggle('hidden', rows.length === 0) els.fleetStrip.classList.toggle('hidden', rows.length === 0)
@@ -854,6 +1041,10 @@ function syncSettingsUi() {
if (els.settingNotify) els.settingNotify.checked = settings.notifyDesktop if (els.settingNotify) els.settingNotify.checked = settings.notifyDesktop
if (els.notifyDesktop) els.notifyDesktop.checked = settings.notifyDesktop if (els.notifyDesktop) els.notifyDesktop.checked = settings.notifyDesktop
if (els.compareToggle) els.compareToggle.checked = settings.comparePeers 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) { if (els.collapseSidebarBtn) {
els.collapseSidebarBtn.textContent = settings.sidebarCollapsed ? '' : '' els.collapseSidebarBtn.textContent = settings.sidebarCollapsed ? '' : ''
} }
@@ -919,6 +1110,29 @@ els.settingNotify?.addEventListener('change', () => {
if (els.settingNotify.checked) ensureDesktopNotifyPermission() 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', () => { els.collapseSidebarBtn?.addEventListener('click', () => {
persist({ sidebarCollapsed: !settings.sidebarCollapsed }) persist({ sidebarCollapsed: !settings.sidebarCollapsed })
syncSettingsUi() syncSettingsUi()
@@ -942,17 +1156,12 @@ els.btnConnect.addEventListener('click', async () => {
try { try {
log('Dialing…') log('Dialing…')
const parsed = classifyConnectionInput(raw) const parsed = classifyConnectionInput(raw)
const conn = await manager.connect(raw, { adminSeed }) const conn = await dialPeer(raw, {
log(`Connected ${conn.publicKeyHex.slice(0, 16)}`) adminSeed,
setOnline(true)
upsertBookmark({
publicKeyHex: conn.publicKeyHex,
alias, alias,
invite: parsed.kind === 'invite' ? raw : null, invite: parsed.kind === 'invite' ? raw : null,
}) })
renderBookmarks() log(`Connected ${conn.publicKeyHex.slice(0, 16)}`)
await manager.request(Methods.subscribeMetrics, { charts: ['*'], intervalMs: 1000 })
await manager.request(Methods.subscribeAnomalies, {})
await ensureDesktopNotifyPermission() await ensureDesktopNotifyPermission()
try { try {
const recent = await manager.request(Methods.listAnomalies, { limit: 20 }) const recent = await manager.request(Methods.listAnomalies, { limit: 20 })
@@ -963,12 +1172,27 @@ els.btnConnect.addEventListener('click', async () => {
} catch { } catch {
// ignore // ignore
} }
await refreshMeta()
await seedHistory()
showView('overview') showView('overview')
log('Subscribed to live metrics') log('Subscribed to live metrics')
} catch (err) { } catch (err) {
log(`Connect failed: ${err.message}`) 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) if (!manager.list().some((c) => c.connected)) setOnline(false)
} finally { } finally {
els.btnConnect.disabled = false els.btnConnect.disabled = false
@@ -1055,11 +1279,61 @@ manager.on('reconnect-exhausted', ({ publicKeyHex }) => {
log(`Reconnect exhausted for ${String(publicKeyHex).slice(0, 12)}`) 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) setOnline(false)
syncSettingsUi() syncSettingsUi()
showView('overview') showView(loadBookmarks().length ? 'fleet' : 'connect')
renderBookmarks() renderBookmarks()
renderPeers() renderPeers()
renderFleetStrip(null, manager.list())
log('PearData ready — connect an agent or pick a saved peer') 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('resize', () => redrawAll())
window.addEventListener('beforeunload', () => {
manager.disconnectAll({ forget: false }).catch(() => {})
})
+35 -99
View File
@@ -1,36 +1,13 @@
/** /**
* Persistent peer bookmarks (pubkey / invite / alias). * Compatibility shim — bookmarks are now the peer roster in peerCache.js.
* Stored under Pear.config.storage or ~/.config/peardata/bookmarks.json.
*/ */
import fs from 'fs' import {
import path from 'path' listPeersAsBookmarks,
import os from 'os' upsertPeer,
setPeerAlias,
const FILE_NAME = 'bookmarks.json' removePeer,
const VERSION = 1 loadPeers,
} from './peerCache.js'
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) : ''
}
/** /**
* @typedef {{ * @typedef {{
@@ -43,73 +20,33 @@ export function bookmarksPath() {
* }} Bookmark * }} Bookmark
*/ */
/** /** @returns {Bookmark[]} */
* @returns {Bookmark[]}
*/
export function loadBookmarks() { export function loadBookmarks() {
const file = bookmarksPath() return listPeersAsBookmarks()
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 []
}
} }
/** /** @param {Bookmark[]} _bookmarks */
* @param {Bookmark[]} bookmarks export function saveBookmarks(_bookmarks) {
*/ // Roster writes go through upsertPeer / removePeer
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 }
)
return true return true
} catch {
return false
}
} }
/** /**
* Upsert a bookmark after a successful connect. * @param {{ publicKeyHex: string, alias?: string, invite?: string|null, capability?: string|null, adminSeed?: string|null }} peer
* @param {{ publicKeyHex: string, alias?: string, invite?: string|null }} peer
*/ */
export function upsertBookmark(peer) { export function upsertBookmark(peer) {
const publicKeyHex = String(peer.publicKeyHex || '').toLowerCase() upsertPeer(
if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) return loadBookmarks() {
const list = loadBookmarks() publicKeyHex: peer.publicKeyHex,
const idx = list.findIndex((b) => b.publicKeyHex === publicKeyHex) alias: peer.alias,
const next = { invite: peer.invite,
id: publicKeyHex, capability: peer.capability,
publicKeyHex, adminSeed: peer.adminSeed,
alias: peer.alias != null ? String(peer.alias).slice(0, 64) : list[idx]?.alias || '',
invite: peer.invite || list[idx]?.invite || null,
lastConnectedAt: Date.now(), lastConnectedAt: Date.now(),
createdAt: list[idx]?.createdAt || Date.now(), },
} { makeActive: true }
if (idx >= 0) list[idx] = next )
else list.unshift(next) return loadBookmarks()
// keep newest 50
saveBookmarks(list.slice(0, 50))
return list
} }
/** /**
@@ -117,21 +54,20 @@ export function upsertBookmark(peer) {
* @param {string} alias * @param {string} alias
*/ */
export function setBookmarkAlias(publicKeyHex, alias) { export function setBookmarkAlias(publicKeyHex, alias) {
const id = String(publicKeyHex || '').toLowerCase() setPeerAlias(publicKeyHex, alias)
const list = loadBookmarks() return loadBookmarks()
const b = list.find((x) => x.publicKeyHex === id)
if (!b) return list
b.alias = String(alias || '').slice(0, 64)
saveBookmarks(list)
return list
} }
/** /**
* @param {string} publicKeyHex * @param {string} publicKeyHex
*/ */
export function removeBookmark(publicKeyHex) { export function removeBookmark(publicKeyHex) {
const id = String(publicKeyHex || '').toLowerCase() removePeer(publicKeyHex)
const list = loadBookmarks().filter((b) => b.publicKeyHex !== id) return loadBookmarks()
saveBookmarks(list)
return list
} }
export function bookmarksPath() {
return ''
}
export { loadPeers }
+133
View File
@@ -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
View File
@@ -1,9 +1,11 @@
/** /**
* Multi-connection manager with reconnect + active selection. * Multi-connection manager with reconnect + active selection.
* PearDock-style: many peers live; one active drives RPC.
*/ */
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { PearDataConnection } from './connection.js' import { PearDataConnection } from './connection.js'
import { classifyConnectionInput } from '../shared/crypto-auth.js' import { classifyConnectionInput } from '../shared/crypto-auth.js'
import { setLastActivePeerId } from './peerCache.js'
export class ConnectionManager extends EventEmitter { export class ConnectionManager extends EventEmitter {
constructor() { constructor() {
@@ -14,6 +16,8 @@ export class ConnectionManager extends EventEmitter {
this.active = null this.active = null
/** @type {Map<string, ReturnType<typeof setTimeout>>} */ /** @type {Map<string, ReturnType<typeof setTimeout>>} */
this._reconnectTimers = new Map() this._reconnectTimers = new Map()
/** @type {Map<string, object>} */
this._reconnectOpts = new Map()
this.maxReconnectTries = this.maxReconnectTries =
Number( Number(
(typeof process !== 'undefined' && process.env?.PEARDATA_MAX_RECONNECT) || 20 (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 {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 = {}) { async connect(input, opts = {}) {
const parsed = typeof input === 'string' ? classifyConnectionInput(input) : input const parsed = typeof input === 'string' ? classifyConnectionInput(input) : input
@@ -44,16 +56,40 @@ export class ConnectionManager extends EventEmitter {
} }
publicKeyHex = String(publicKeyHex).toLowerCase() 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, capability,
adminSeed: opts.adminSeed || null, 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', () => { conn.on('disconnected', () => {
this.emit('disconnected', conn) 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('push', (ev) => this.emit('push', ev, conn))
conn.on('error', (err) => this.emit('error', err, conn)) conn.on('error', (err) => this.emit('error', err, conn))
@@ -61,18 +97,36 @@ export class ConnectionManager extends EventEmitter {
await conn.connect() await conn.connect()
this.connections.set(publicKeyHex, conn) this.connections.set(publicKeyHex, conn)
this._tries.set(publicKeyHex, 0) 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) this.emit('connected', conn)
return conn return conn
} }
/** /**
* @param {string} publicKeyHex * @param {string} publicKeyHex
* @param {{ persist?: boolean }} [opts]
*/ */
setActive(publicKeyHex) { setActive(publicKeyHex, opts = {}) {
const conn = this.connections.get(String(publicKeyHex).toLowerCase()) const id = String(publicKeyHex).toLowerCase()
const conn = this.connections.get(id)
if (!conn) return false if (!conn) return false
this.active = conn this.active = conn
if (opts.persist !== false) {
try {
setLastActivePeerId(id)
} catch {
// ignore
}
}
this.emit('active', conn) this.emit('active', conn)
return true return true
} }
@@ -96,10 +150,13 @@ export class ConnectionManager extends EventEmitter {
/** /**
* @param {string} [publicKeyHex] * @param {string} [publicKeyHex]
* @param {{ forgetReconnect?: boolean }} [opts]
*/ */
async disconnect(publicKeyHex) { async disconnect(publicKeyHex, opts = {}) {
if (!publicKeyHex) { 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 return
} }
const id = String(publicKeyHex).toLowerCase() const id = String(publicKeyHex).toLowerCase()
@@ -108,6 +165,10 @@ export class ConnectionManager extends EventEmitter {
clearTimeout(timer) clearTimeout(timer)
this._reconnectTimers.delete(id) this._reconnectTimers.delete(id)
} }
if (opts.forgetReconnect !== false) {
this._reconnectOpts.delete(id)
this._tries.delete(id)
}
const conn = this.connections.get(id) const conn = this.connections.get(id)
if (conn) { if (conn) {
this.connections.delete(id) 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) { _scheduleReconnect(publicKeyHex, opts) {
const id = String(publicKeyHex).toLowerCase() const id = String(publicKeyHex).toLowerCase()
if (this._reconnectTimers.has(id)) return if (this._reconnectTimers.has(id)) return
const tries = (this._tries.get(id) || 0) + 1 const tries = (this._tries.get(id) || 0) + 1
this._tries.set(id, tries) 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 }) this.emit('reconnect-exhausted', { publicKeyHex: id, tries })
return return
} }
@@ -129,7 +203,16 @@ export class ConnectionManager extends EventEmitter {
const timer = setTimeout(async () => { const timer = setTimeout(async () => {
this._reconnectTimers.delete(id) this._reconnectTimers.delete(id)
try { 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) { } catch (err) {
this.emit('reconnect-failed', { publicKeyHex: id, err, tries }) this.emit('reconnect-failed', { publicKeyHex: id, err, tries })
this._scheduleReconnect(id, opts) this._scheduleReconnect(id, opts)
+41
View File
@@ -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')
}
+308
View File
@@ -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
View File
@@ -1,13 +1,16 @@
/** /**
* Desktop UI preferences (theme, density, chart options). * Desktop UI preferences — PearDock-style cache + FOUC localStorage mirror.
* Stored under Pear.config.storage or ~/.config/peardata/ui-settings.json. *
* Primary: ~/.config/peardata/cache/settings.json
* Mirror: localStorage peardata.settings.v1
*/ */
import fs from 'fs' import fs from 'fs'
import path from 'path' 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' export const SETTINGS_CACHE_VERSION = 1
const VERSION = 1 export const SETTINGS_LOCALSTORAGE_KEY = 'peardata.settings.v1'
/** @typedef {{ /** @typedef {{
* theme: 'dark'|'light', * theme: 'dark'|'light',
@@ -19,6 +22,15 @@ const VERSION = 1
* chartPoints: number, * chartPoints: number,
* defaultExplore: string, * defaultExplore: string,
* reduceMotion: boolean, * 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 */ * }} UiSettings */
/** @returns {UiSettings} */ /** @returns {UiSettings} */
@@ -33,55 +45,110 @@ export function defaultSettings() {
chartPoints: 90, chartPoints: 90,
defaultExplore: 'system.io', defaultExplore: 'system.io',
reduceMotion: false, reduceMotion: false,
metricsCardHeight: 160,
metricsDimSort: 'name',
metricsCollapsed: [],
metricsChartTypes: {},
metricsPinned: [],
metricsGroup: 'average',
metricsForcePlay: false,
reconnectMaxAttempts: 20,
autoRestorePeers: true,
} }
} }
function homeDir() { export function getSettingsCachePath() {
if (typeof globalThis !== 'undefined' && globalThis.Pear?.config?.storage) { return path.join(getPeardataCacheDir(), 'settings.json')
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')
} }
/** @deprecated use getSettingsCachePath */
export function settingsPath() { 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() { export function loadSettings() {
const defaults = defaultSettings() const defaults = defaultSettings()
try { let fromFile = readSettingsFile(getSettingsCachePath())
const raw = JSON.parse(fs.readFileSync(settingsPath(), 'utf8')) if (!fromFile) {
return { ...defaults, ...(raw?.settings || raw || {}) } fromFile = migrateLegacySettings()
} catch { if (fromFile) {
return defaults 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 */ /** @param {Partial<UiSettings>} patch */
export function saveSettings(patch) { export function saveSettings(patch) {
const next = { ...loadSettings(), ...patch } const next = { ...loadSettings(), ...patch }
const dir = homeDir() const payload = {
version: SETTINGS_CACHE_VERSION,
updatedAt: new Date().toISOString(),
settings: next,
}
try { try {
fs.mkdirSync(dir, { recursive: true }) atomicWriteJson(getSettingsCachePath(), payload)
fs.writeFileSync(
settingsPath(),
JSON.stringify({ version: VERSION, settings: next, updatedAt: new Date().toISOString() }, null, 2)
)
} catch { } 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 return next
} }
/** /**
* Apply settings to document body/html datasets (PearDock-style).
* @param {UiSettings} s * @param {UiSettings} s
*/ */
export function applySettingsToDom(s) { export function applySettingsToDom(s) {
+29 -20
View File
@@ -51,38 +51,46 @@ Do not name third-party products in code, commits, or user-facing copy.
### P1 — Interaction parity ### P1 — Interaction parity
- [ ] Pan / zoom / reset on a card (updates shared window) - [x] Pan / zoom / reset on a card (updates shared window)
- [ ] Synced crosshair / shared hover time across visible cards - [x] Synced crosshair / shared hover time across visible cards
- [ ] Dimension show/hide + sort by name / latest value - [x] Dimension show/hide + sort by name / latest value
- [ ] Chart type switch (line / area / stacked) where units allow - [x] Chart type switch (line / area / stacked) where units allow
- [ ] Resize card height; persist prefs - [x] Resize card height; persist prefs
- [ ] Section overview KPI strip (latest values) above detail charts - [x] Section overview KPI strip (latest values) above detail charts
### P2 — Filters & fleet scope ### P2 — Filters & fleet scope
- [ ] Search by id **or** title / context / family / plugin - [x] Search by id **or** title / context / family / plugin
- [ ] Host/agent chip reseed on active peer switch - [x] Host/agent chip reseed on active peer switch
- [ ] Optional group aggregation UI (`average` / `min` / `max` / `sum`) - [x] Optional group aggregation UI (`average` / `min` / `max` / `sum`)
- [ ] Tier / resolution hint when HyperDB warm is used - [x] Tier / resolution hint when HyperDB warm is used (card status)
### P3 — Investigation ### P3 — Investigation
- [ ] Expand card: stats table (min/avg/max), dim table - [x] Expand card: stats table (min/avg/max), dim table (click title)
- [ ] Alert click → Charts wall, scroll to chart, pause at event time - [x] Alert click → Charts wall, scroll to chart, pause near event time
- [ ] Highlight window for “related metrics” (uses `/weights` when ready) - [x] Related metrics panel (context/family + series correlation)
- [ ] Anomaly tint / threshold line on wall cards - [x] Anomaly tint / threshold line on wall cards
### P4 — Shell & persistence ### P4 — Shell & persistence
- [ ] Persist pinned charts, collapsed sections, card heights - [x] Persist collapsed sections, card heights, dim sort, chart types
- [ ] Custom board view reusing the same card component (later) - [x] Persist pinned charts
- [ ] Wallboard / force-play mode (later) - [x] Board mode (pinned-only wall reusing the same cards)
- [x] Wallboard / force-play mode
### P5 — Authoring model ### P5 — Authoring model
- [ ] Versioned `shared/taxonomy.js` (contexts → sections) - [x] Versioned `shared/taxonomy.js` (contexts → sections)
- [ ] New collectors register context + priority used by TOC - [x] EXTENDING.md documents taxonomy placement for new charts
- [ ] Optional CI check: new contexts appear in taxonomy or “Other” - [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, `15` 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 | | Path | Role |
|------|------| |------|------|
| `shared/taxonomy.js` | Section defs + `groupCatalog()` | | `shared/taxonomy.js` | Section defs + `groupCatalog()` |
| `shared/related-metrics.js` | Related-chart ranking |
| `ui/dashboard.js` | Metrics wall controller | | `ui/dashboard.js` | Metrics wall controller |
| `ui/charts.js` | Canvas paint + hover helpers | | `ui/charts.js` | Canvas paint + hover helpers |
| `index.html` | Charts view shell (TOC + wall + time bar) | | `index.html` | Charts view shell (TOC + wall + time bar) |
+1
View File
@@ -152,6 +152,7 @@ Aggregate: any CRITICAL → `critical`; else any WARNING → `degraded`; else `o
|---------|-----|------| |---------|-----|------|
| Charts | `listCharts` | `GET /api/v1/charts` | | Charts | `listCharts` | `GET /api/v1/charts` |
| Data | `queryData` | `GET /api/v3/data` | | Data | `queryData` | `GET /api/v3/data` |
| Weights | `getWeights` | `GET /api/v3/weights` |
| Contexts | `listContexts` | `GET /api/v3/contexts` | | Contexts | `listContexts` | `GET /api/v3/contexts` |
| Nodes | `getNodeInfo` | `GET /api/v3/nodes` | | Nodes | `getNodeInfo` | `GET /api/v3/nodes` |
| Alerts | `listAlerts` | `GET /api/v3/alerts` | | Alerts | `listAlerts` | `GET /api/v3/alerts` |
+30 -7
View File
@@ -132,26 +132,49 @@ Override home with `PEARDATA_HOME` if you need isolation (CI, multi-profile).
| Module | Responsibility | | 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/identity.js` | Load/create keypair on disk |
| `client/connection.js` | Single HyperDHT + protomux-rpc session | | `client/connection.js` | Single HyperDHT + protomux-rpc session |
| `client/manager.js` | Multi-peer map, active selection, reconnect | | `client/manager.js` | Multi-peer map, active selection, reconnect |
| `client/errors.js` | Unwrap / normalize RPC errors for UI | | `client/errors.js` | Unwrap / normalize RPC errors for UI |
| `app.js` | Wire DOM to manager + protocol methods | | `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 ### Manager reconnect
- Default max tries: `PEARDATA_MAX_RECONNECT` or **20** - Default max tries: settings `reconnectMaxAttempts` or `PEARDATA_MAX_RECONNECT` / **20**
- `connect(input, { adminSeed, autoReconnect })` - `connect(input, { adminSeed, autoReconnect, skipActivate })`
- Input may be **64-hex public key** or **`pd1.` invite** - Input may be **64-hex public key** or **`pd1.` invite**
## LocalStorage keys (demo UI) ## LocalStorage mirrors
| Key | Purpose | | Key | Purpose |
|-----|---------| |-----|---------|
| `peardata:last-connect` | Last public key / invite string | | `peardata.settings.v1` | Flat settings FOUC + offline mirror |
| `peardata:display-name` | Last display name | | `peardata-ui-boot` | Theme-only early paint |
| `peardata_active_peer_id` | Last active peer mirror |
These are demo convenience only — production apps often prefer a file under app storage. | `peardata.cache.peers` | Peers envelope mirror |
## Development tips ## Development tips
+5 -2
View File
@@ -5,8 +5,11 @@
1. Define the chart in `shared/metrics.js` (`STATIC_CHART_DEFS`, or `registerChart()` for instances). 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). 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`. 3. Store + REST/RPC pick it up automatically via `getAllChartDefs()` / `CHART_BY_ID`.
4. Document dimensions in [DATA-MODEL.md](./DATA-MODEL.md). 4. Ensure the chart lands in the Charts wall TOC via `shared/taxonomy.js` (`sectionForChart` matchers). Prefer a real section over **Other**.
5. Optionally add a canvas panel in `index.html` + `app.js`. 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 ## Add an RPC method
+2 -2
View File
@@ -103,7 +103,7 @@ Single-agent returns one node. With `PEARDATA_PARENT=1`, `/nodes` and `/fleet` i
| Method | Path | Notes | | Method | Path | Notes |
|--------|------|-------| |--------|------|-------|
| GET | `/api/v3/q?q=` | Full-text over chart ids/titles | | 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 ### Alerts
@@ -145,7 +145,7 @@ Single-agent returns one node. With `PEARDATA_PARENT=1`, `/nodes` and `/fleet` i
| Area | PearData | | 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 | | Multi-node parent streaming | Single node; parent planned |
| Cloud POST spaces APIs | Not implemented (agent GET style only) | | Cloud POST spaces APIs | Not implemented (agent GET style only) |
| App/plugin charts (nginx, DB, …) | System/OS charts; plugins later | | App/plugin charts (nginx, DB, …) | System/OS charts; plugins later |
+15 -8
View File
@@ -92,7 +92,7 @@ Template rebrand, protocol, collector, memory store, anomalies, REST, desktop MV
| Anomaly scoring + UI highlight | Done (score + panel tint + threshold line) | | Anomaly scoring + UI highlight | Done (score + panel tint + threshold line) |
| Notifications | Done (desktop + `PEARDATA_WEBHOOK_URL`) | | Notifications | Done (desktop + `PEARDATA_WEBHOOK_URL`) |
| Streaming z-score / retrain job | Done (`PEARDATA_ANOMALY_MODE`, job `retrainAnomaly`) | | 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 | | 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` | | Signed / notarized macOS clients | Beyond ad-hoc `rcodesign` |
| Role templates | viewer / SRE / admin presets | | Role templates | viewer / SRE / admin presets |
| Plugin SDK | Collector + chart registration API | | 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 | | Global time bar (play/pause, presets) | Done |
| Multi-dimension live + history cards | Done | | Multi-dimension live + history cards | Done |
| Lazy visible-card query / paint | Done | | Lazy visible-card query / paint | Done |
| Dim show/hide chips + basic synced hover | Done (P0) | | Dim show/hide chips + basic synced hover | Done |
| Pan/zoom / shared window from gestures | Planned (P1) | | Pan/zoom/reset + shared window from gestures | Done (P1) |
| Alert → scroll-to-chart | Done (pause-at-event TBD P3) | | Section KPIs, chart type, dim sort, resize | Done (P1) |
| Persist layout prefs / pinned charts | Planned (P4) | | 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 **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. 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`) 13. ~~Webhook HMAC signing~~ ✅ (`X-PearData-Signature`)
14. ~~z-score / retrain job~~ 14. ~~z-score / retrain job~~
15. ~~REST HyperDHT tunnel~~ ✅ (`PEARDATA_REST_TUNNEL=1`) 15. ~~REST HyperDHT tunnel~~ ✅ (`PEARDATA_REST_TUNNEL=1`)
16. **Master metrics dashboard (Charts)** Phase 6 P0 ← **now** 16. ~~Master metrics dashboard (Charts)~~ Phase 6 core (wall, pins, related, weights)
17. Dashboard P1 — sync crosshair / pan-zoom / dim picker 17. Multi-named custom boards / Autobase parents — later
18. Autobase multi-writer parents — HA fleet history 18. Autobase multi-writer parents — HA fleet history
19. Windows collector depth — close `/proc`-only gaps 19. Windows collector depth — close `/proc`-only gaps
20. Plugin SDK polish — public collector registration API 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) | ✅ | | M4 | Historical scrub across restart (HyperDB warm) | ✅ |
| M5 | Container charts from Docker hosts | ✅ opt-in spike | | M5 | Container charts from Docker hosts | ✅ opt-in spike |
| M6 | Parent peer rolling up a fleet | ✅ 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
View File
@@ -12,8 +12,10 @@
/> />
<script> <script>
try { try {
var s = JSON.parse(localStorage.getItem('peardata-ui-boot') || '{}') var raw = localStorage.getItem('peardata.settings.v1') || localStorage.getItem('peardata-ui-boot') || '{}'
if (s.theme) document.documentElement.dataset.theme = s.theme var s = JSON.parse(raw)
var theme = s.theme || (s.settings && s.settings.theme)
if (theme) document.documentElement.dataset.theme = theme
} catch (e) {} } catch (e) {}
</script> </script>
<link rel="stylesheet" href="./ui/styles.css" /> <link rel="stylesheet" href="./ui/styles.css" />
@@ -72,6 +74,9 @@
<div id="offline-banner" class="offline-banner hidden" role="status"> <div id="offline-banner" class="offline-banner hidden" role="status">
Agent offline — showing last-known samples. Reconnecting… Agent offline — showing last-known samples. Reconnecting…
</div> </div>
<div id="restoring-banner" class="offline-banner restoring-banner hidden" role="status">
Restoring saved agents…
</div>
<!-- Overview --> <!-- Overview -->
<section id="overview-view" class="view"> <section id="overview-view" class="view">
@@ -174,10 +179,13 @@
<div> <div>
<p class="dash-kicker">Metrics</p> <p class="dash-kicker">Metrics</p>
<h1 class="dash-title">Charts</h1> <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>15</kbd> window</p>
</div> </div>
<div class="metrics-timebar" id="metrics-timebar"> <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-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"> <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" data-preset="1m">1m</button>
<button type="button" class="ghost active" data-preset="5m">5m</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="1h">1h</button>
<button type="button" class="ghost" data-preset="6h">6h</button> <button type="button" class="ghost" data-preset="6h">6h</button>
</div> </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-meta" class="muted metrics-meta"></span>
<span id="metrics-hover" class="muted metrics-hover"></span> <span id="metrics-hover" class="muted metrics-hover"></span>
</div> </div>
</header> </header>
<div id="metrics-related" class="dash-card related-panel hidden"></div>
<div class="metrics-shell"> <div class="metrics-shell">
<aside class="metrics-toc dash-card"> <aside class="metrics-toc dash-card">
<input id="chart-search" type="search" placeholder="Filter by id, title, family…" autocomplete="off" /> <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"> <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 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="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="notifications">Notifications</button>
<button type="button" class="settings-tab" data-settings-tab="about">About</button> <button type="button" class="settings-tab" data-settings-tab="about">About</button>
</div> </div>
@@ -343,6 +363,25 @@
</div> </div>
</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="settings-panel" data-settings-panel="notifications">
<div class="dash-card settings-section"> <div class="dash-card settings-section">
<h3>Desktop</h3> <h3>Desktop</h3>
+6
View File
@@ -147,6 +147,12 @@ export function registerMonitorHandlers(session) {
}) })
session.respond('queryData', async (args) => store.query(args), { hot: true }) 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 () => { session.respond('getDbInfo', async () => {
const db = getDb() const db = getDb()
+6 -10
View File
@@ -157,17 +157,13 @@ export async function handleRest(pathname, query) {
} }
// ── weights / q (stubs with useful MVP behavior) ───────── // ── weights / q (stubs with useful MVP behavior) ─────────
if (path === '/api/v3/weights' || path === '/api/v2/weights') { if (path === '/api/v3/weights' || path === '/api/v2/weights' || path === '/api/v1/weights') {
const health = getAnomalyEngine().getHealth() return json(
return json({ getAnomalyEngine().getWeights({
status: health.status, chart: query.get('chart') || query.get('context') || undefined,
score: health.score, limit: Number(query.get('limit') || query.get('points') || 100) || 100,
results: health.checks.map((c) => ({
id: c.id,
weight: c.ok ? 0 : 1,
info: c.detail,
})),
}) })
)
} }
if (path === '/api/v3/q' || path === '/api/v2/q') { if (path === '/api/v3/q' || path === '/api/v2/q') {
const q = (query.get('q') || query.get('query') || '').toLowerCase() const q = (query.get('q') || query.get('query') || '').toLowerCase()
+55
View File
@@ -364,6 +364,61 @@ export class AnomalyEngine extends EventEmitter {
const score = critical ? 0.2 : warning ? 0.7 : 1 const score = critical ? 0.2 : warning ? 0.7 : 1
return { status, score, checks, ts: Date.now() } 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) { function round2(n) {
+1
View File
@@ -60,6 +60,7 @@ export const MethodRoles = Object.freeze({
getChart: Roles.viewer, getChart: Roles.viewer,
queryData: Roles.viewer, queryData: Roles.viewer,
getAllMetrics: Roles.viewer, getAllMetrics: Roles.viewer,
getWeights: Roles.viewer,
// live subscription control // live subscription control
subscribeMetrics: Roles.viewer, subscribeMetrics: Roles.viewer,
+139
View File
@@ -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
}
+1
View File
@@ -31,6 +31,7 @@ export function validateMethodArgs(method, args = {}) {
case 'listAlerts': case 'listAlerts':
case 'listJobs': case 'listJobs':
case 'listPeers': case 'listPeers':
case 'getWeights':
return { ok: true, args } return { ok: true, args }
case 'setDisplayName': { case 'setDisplayName': {
+28
View File
@@ -40,3 +40,31 @@ test('evaluate includes continuous score in message', (t) => {
t.ok(fired[0].score >= 0.6) t.ok(fired[0].score >= 0.6)
t.ok(String(fired[0].message).includes('score')) 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')
})
+9 -7
View File
@@ -7,15 +7,15 @@ import {
upsertBookmark, upsertBookmark,
removeBookmark, removeBookmark,
setBookmarkAlias, setBookmarkAlias,
bookmarksPath,
} from '../client/bookmarks.js' } from '../client/bookmarks.js'
import { getPeersCachePath } from '../client/peerCache.js'
const tmp = path.join(os.tmpdir(), `peardata-bm-${Date.now()}`)
test('bookmarks upsert alias remove', (t) => { 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 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) const pk = 'a'.repeat(64)
upsertBookmark({ publicKeyHex: pk, alias: 'box-1' }) upsertBookmark({ publicKeyHex: pk, alias: 'box-1' })
@@ -29,11 +29,13 @@ test('bookmarks upsert alias remove', (t) => {
removeBookmark(pk) removeBookmark(pk)
t.is(loadBookmarks().length, 0) t.is(loadBookmarks().length, 0)
} finally {
if (prev === undefined) delete process.env.PEARDATA_HOME
else process.env.PEARDATA_HOME = prev
try { try {
fs.rmSync(tmp, { recursive: true, force: true }) fs.rmSync(tmp, { recursive: true, force: true })
} catch { } catch {
// ignore // ignore
} }
delete process.env.PEARDATA_HOME }
}) })
+82
View File
@@ -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)
})
})
+1
View File
@@ -39,6 +39,7 @@ test('method roles cover monitoring surface', (t) => {
'unlinkPeer', 'unlinkPeer',
'getFleetHealth', 'getFleetHealth',
'listChildPeers', 'listChildPeers',
'getWeights',
]) { ]) {
t.ok(MethodRoles[m], m) t.ok(MethodRoles[m], m)
t.is(Methods[m], m) t.is(Methods[m], m)
+40
View File
@@ -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'))
})
+36
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+264
View File
@@ -448,6 +448,19 @@ body.sidebar-collapsed #conn-meta {
color: var(--text-muted); 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 { .offline-banner {
margin: calc(-1 * var(--space-lg) + 4px) calc(-1 * var(--space-lg) + 4px) var(--space) ; margin: calc(-1 * var(--space-lg) + 4px) calc(-1 * var(--space-lg) + 4px) var(--space) ;
padding: 10px 16px; padding: 10px 16px;
@@ -627,6 +640,15 @@ body.is-offline .offline-banner:not(.hidden) {
gap: 2px; 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 { .fleet-children li strong {
font-size: 13px; font-size: 13px;
} }
@@ -662,6 +684,17 @@ body.is-offline .offline-banner:not(.hidden) {
gap: 12px 20px; 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 { .metrics-timebar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -688,6 +721,107 @@ body.is-offline .offline-banner:not(.hidden) {
font-family: var(--font-mono); 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 { .metrics-shell {
display: grid; display: grid;
grid-template-columns: 240px minmax(0, 1fr); 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); 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 { .metrics-detail {
margin-top: var(--space); margin-top: var(--space);
} }