updates
CI / test (push) Successful in 58s
Release rolling / release (push) Successful in 7m11s

This commit is contained in:
Raven Scott
2026-07-18 22:15:43 -04:00
parent 76d43bfdbd
commit 6d384534f2
9 changed files with 363 additions and 289 deletions
+49 -76
View File
@@ -41,8 +41,6 @@ const els = {
btnDisconnect: $('btn-disconnect'), btnDisconnect: $('btn-disconnect'),
btnInvite: $('btn-invite'), btnInvite: $('btn-invite'),
btnRefreshMeta: $('btn-refresh-meta'), btnRefreshMeta: $('btn-refresh-meta'),
peerList: $('peer-list'),
bookmarkList: $('bookmark-list'),
compareToggle: $('compare-toggle'), compareToggle: $('compare-toggle'),
exploreChart: $('explore-chart'), exploreChart: $('explore-chart'),
chartCpuLabel: $('chart-cpu-label'), chartCpuLabel: $('chart-cpu-label'),
@@ -73,11 +71,6 @@ const els = {
statNet: $('stat-net'), statNet: $('stat-net'),
statHealth: $('stat-health'), statHealth: $('stat-health'),
chartSearch: $('chart-search'), chartSearch: $('chart-search'),
chartCatalogList: $('chart-catalog-list'),
chartDetailTitle: $('chart-detail-title'),
chartDetailMeta: $('chart-detail-meta'),
chartDetailDims: $('chart-detail-dims'),
chartDetailPanel: $('chart-detail-panel'),
metricsToc: $('metrics-toc'), metricsToc: $('metrics-toc'),
metricsWall: $('metrics-wall'), metricsWall: $('metrics-wall'),
metricsPlay: $('metrics-play'), metricsPlay: $('metrics-play'),
@@ -118,7 +111,6 @@ try {
/** @type {Record<string, object>} */ /** @type {Record<string, object>} */
let chartCatalog = {} let chartCatalog = {}
let selectedCatalogChart = ''
let exploreChartId = settings.defaultExplore || 'system.io' let exploreChartId = settings.defaultExplore || 'system.io'
/** @type {Map<string, { severity: string, score: number, threshold: number|null, until: number }>} */ /** @type {Map<string, { severity: string, score: number, threshold: number|null, until: number }>} */
@@ -139,12 +131,14 @@ const series = {
ioWrite: [], ioWrite: [],
load: [], load: [],
explore: [], explore: [],
detail: [],
} }
/** Per-peer CPU series for compare mode @type {Map<string, number[]>} */ /** Per-peer CPU series for compare mode @type {Map<string, number[]>} */
const peerCpu = new Map() const peerCpu = new Map()
/** @type {object|null} last getFleetHealth payload for Fleet card chips */
let lastFleetHealth = null
const metricsDashboard = createMetricsDashboard({ const metricsDashboard = createMetricsDashboard({
els: { els: {
root: $('charts-view'), root: $('charts-view'),
@@ -168,7 +162,7 @@ const metricsDashboard = createMetricsDashboard({
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), isConnected: () => Boolean(manager.active?.connected),
getPrefs: () => ({ getPrefs: () => ({
cardHeight: settings.metricsCardHeight, cardHeight: settings.metricsCardHeight,
dimSort: settings.metricsDimSort, dimSort: settings.metricsDimSort,
@@ -199,6 +193,15 @@ const metricsDashboard = createMetricsDashboard({
}) })
function seriesMax() { function seriesMax() {
// Overview sparks follow Charts' active window when available (capped for density)
try {
const after = metricsDashboard?.getState?.()?.afterSeconds
if (after != null && Number(after) > 0) {
return Math.max(30, Math.min(300, Math.round(Number(after))))
}
} catch {
// ignore
}
return Math.max(30, Math.min(300, Number(settings.chartPoints) || 90)) return Math.max(30, Math.min(300, Number(settings.chartPoints) || 90))
} }
@@ -473,9 +476,6 @@ function redrawAll() {
mode: defaultModeFromMeta(chartCatalog[exploreChartId] || { chartType: 'line' }), mode: defaultModeFromMeta(chartCatalog[exploreChartId] || { chartType: 'line' }),
} }
) )
if (selectedCatalogChart && series.detail.length) {
paint('chart-detail', [{ values: series.detail, color: CHART_PALETTE.cpuUser }])
}
} }
function updateActivePeerChip() { function updateActivePeerChip() {
@@ -508,6 +508,7 @@ function loadFleetView() {
activeId: manager.active?.publicKeyHex || null, activeId: manager.active?.publicKeyHex || null,
getReconnectInfo: (id) => manager.getReconnectInfo(id), getReconnectInfo: (id) => manager.getReconnectInfo(id),
}) })
enrichFleetMetrics(roster)
const summary = summarizeFleet(roster) const summary = summarizeFleet(roster)
if (els.fleetStatLive) els.fleetStatLive.textContent = String(summary.live) if (els.fleetStatLive) els.fleetStatLive.textContent = String(summary.live)
if (els.fleetStatRetry) els.fleetStatRetry.textContent = String(summary.reconnecting) if (els.fleetStatRetry) els.fleetStatRetry.textContent = String(summary.reconnecting)
@@ -515,6 +516,7 @@ function loadFleetView() {
if (els.fleetStatFailed) els.fleetStatFailed.textContent = String(summary.failed) if (els.fleetStatFailed) els.fleetStatFailed.textContent = String(summary.failed)
renderFleetCards(els.fleetCards, roster, { renderFleetCards(els.fleetCards, roster, {
onConnect: () => showView('connect'),
onActivate: async (peer) => { onActivate: async (peer) => {
try { try {
await activatePeer(peer.publicKeyHex) await activatePeer(peer.publicKeyHex)
@@ -576,6 +578,38 @@ function loadFleetView() {
}) })
} }
/** Attach CPU/RAM/health chips from parent fleet health + active overview series. */
function enrichFleetMetrics(roster) {
/** @type {Map<string, { cpu?: number, ram?: number, health?: string }>} */
const byKey = new Map()
const children = lastFleetHealth?.children
if (Array.isArray(children)) {
for (const c of children) {
const id = String(c.publicKeyHex || '').toLowerCase()
if (!id) continue
byKey.set(id, {
cpu: c.cpu != null ? Number(c.cpu) : undefined,
ram: c.ram != null ? Number(c.ram) : undefined,
health: c.health || undefined,
})
}
}
for (const peer of roster) {
const h = byKey.get(peer.id)
if (h) {
if (h.cpu != null) peer.cpu = h.cpu
if (h.ram != null) peer.ram = h.ram
if (h.health) peer.health = h.health
}
if (peer.active && peer.connected) {
const cpu = series.cpu?.[series.cpu.length - 1]
const ram = series.ram?.[series.ram.length - 1]
if (cpu != null) peer.cpu = cpu
if (ram != null) peer.ram = ram
}
}
}
function exploreValue(chart, values) { function exploreValue(chart, values) {
if (!values) return 0 if (!values) return 0
if (chart === 'system.io' || chart.startsWith('disk_io.')) { if (chart === 'system.io' || chart.startsWith('disk_io.')) {
@@ -702,10 +736,6 @@ function onSamples(samples, conn) {
if (s.chart === exploreChartId) { if (s.chart === exploreChartId) {
pushPoint('explore', exploreValue(exploreChartId, s.values)) pushPoint('explore', exploreValue(exploreChartId, s.values))
} }
if (s.chart === selectedCatalogChart) {
pushPoint('detail', exploreValue(selectedCatalogChart, s.values))
paint('chart-detail', [{ values: series.detail, color: CHART_PALETTE.cpuUser }])
}
} }
// Metrics wall only ingests the active agent // Metrics wall only ingests the active agent
if (isActive) metricsDashboard.onSamples(samples || []) if (isActive) metricsDashboard.onSamples(samples || [])
@@ -728,7 +758,6 @@ function prependAnomaly(ev) {
li.addEventListener('click', () => { li.addEventListener('click', () => {
showView('charts') showView('charts')
metricsDashboard.focusChartAt(ev.chart, ev.ts) metricsDashboard.focusChartAt(ev.chart, ev.ts)
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
exploreChartId = ev.chart exploreChartId = ev.chart
@@ -847,64 +876,6 @@ function renderChartCatalog(_filter = '') {
metricsDashboard.setCatalog(chartCatalog) metricsDashboard.setCatalog(chartCatalog)
} }
async function selectCatalogChart(id) {
selectedCatalogChart = id
series.detail = []
const meta = chartCatalog[id] || {}
if (els.chartDetailPanel) els.chartDetailPanel.classList.remove('hidden')
if (els.chartDetailTitle) els.chartDetailTitle.textContent = meta.title || id
if (els.chartDetailMeta) {
els.chartDetailMeta.textContent = [meta.context, meta.family, meta.units]
.filter(Boolean)
.join(' · ')
}
if (els.chartDetailDims) {
const dims = meta.dimensions || meta.labels || []
els.chartDetailDims.textContent = Array.isArray(dims)
? dims
.map((d) => (typeof d === 'string' ? d : d.id || d.name))
.filter(Boolean)
.join('\n')
: JSON.stringify(meta, null, 2)
}
try {
const q = await manager.request(Methods.queryData, {
chart: id,
after: -seriesMax(),
points: seriesMax(),
})
series.detail = []
/** @type {Array<{ values: number[], color: string, label: string }>} */
const lines = []
const labels = (q.labels || []).filter((l) => l && l !== 'time')
if (labels.length) {
for (let i = 0; i < labels.length; i++) {
const values = []
for (const row of q.data || []) values.push(Number(row[i + 1]) || 0)
lines.push({
values,
color: CHART_PALETTE.compare[i % CHART_PALETTE.compare.length],
label: labels[i],
})
if (i === 0) series.detail = values.slice()
}
paint('chart-detail', lines, {
mode: defaultModeFromMeta(meta),
showYAxis: defaultModeFromMeta(meta) !== 'pie',
})
} else {
for (const row of q.data || []) pushPoint('detail', row[1] ?? 0)
paint('chart-detail', [{ values: series.detail, color: CHART_PALETTE.cpuUser }], {
showYAxis: true,
})
}
} catch {
paint('chart-detail', [{ values: series.detail, color: CHART_PALETTE.cpuUser }], {
showYAxis: true,
})
}
}
async function refreshMeta() { async function refreshMeta() {
const [info, auth, health, node, fleet] = await Promise.all([ const [info, auth, health, node, fleet] = await Promise.all([
manager.request(Methods.getServerInfo, {}), manager.request(Methods.getServerInfo, {}),
@@ -913,6 +884,8 @@ async function refreshMeta() {
manager.request(Methods.getNodeInfo, {}), manager.request(Methods.getNodeInfo, {}),
manager.request(Methods.getFleetHealth, {}).catch(() => ({ enabled: false })), manager.request(Methods.getFleetHealth, {}).catch(() => ({ enabled: false })),
]) ])
lastFleetHealth = fleet || null
if (els.fleetCards) loadFleetView()
if (els.serverInfo) { if (els.serverInfo) {
els.serverInfo.textContent = JSON.stringify({ info, node, health, fleet }, null, 2) els.serverInfo.textContent = JSON.stringify({ info, node, health, fleet }, null, 2)
} }
+19 -11
View File
@@ -7,17 +7,17 @@ Related: [ROADMAP.md](./ROADMAP.md) Phase 6 · [DESKTOP.md](./DESKTOP.md) · [DA
--- ---
## Current state (baseline) ## Current state
| Surface | Today | | Surface | Today |
|---------|--------| |---------|--------|
| Overview | Fixed 6-panel live grid + KPIs | | Overview | Compact 6-panel live grid + KPIs; spark depth follows Charts timeframe when available |
| Charts | Flat searchable id list + single scalar detail canvas | | Charts | Sectioned metrics wall (TOC + cards), shared time bar, multi-dim series |
| Time | Settings “points per chart” only | | Time | Charts presets (1m6h) + play/pause; retention is advisory (`thin-history`) |
| Dimensions | Text dump; live path collapses to one number | | Dimensions | Legend chips show/hide; chart type cycle (line/area/stacked/bar/pie) |
| Alerts → chart | Jumps to Overview spotlight, not Charts wall | | Alerts → chart | Charts wall scroll-to-card + pause near event |
Backend already has `listCharts`, `queryData` (multi-dim labels), live `subscribeMetrics`, contexts REST — the desktop Charts UI under-uses them. Backend `listCharts` / `queryData` / live subscribe power the wall over HyperDHT RPC.
--- ---
@@ -36,7 +36,7 @@ Do not name third-party products in code, commits, or user-facing copy.
## Phase 6 checklist ## Phase 6 checklist
### P0 — Foundation (in progress) ### P0 — Foundation
- [x] Roadmap + this doc - [x] Roadmap + this doc
- [x] Long-scroll **metrics wall** (not one-chart-at-a-time) - [x] Long-scroll **metrics wall** (not one-chart-at-a-time)
@@ -79,7 +79,7 @@ Do not name third-party products in code, commits, or user-facing copy.
- [x] Board mode (pinned-only wall reusing the same cards) - [x] Board mode (pinned-only wall reusing the same cards)
- [x] Wallboard / force-play mode - [x] Wallboard / force-play mode
- [x] Stale-while-revalidate paint (no refetch flicker) - [x] Stale-while-revalidate paint (no refetch flicker)
- [x] Retention-aware time presets (disable windows with no history yet) - [x] Retention-aware time presets (advisory `thin-history` hint; presets stay selectable)
- [x] Live / Paused / Force indicator on Charts toolbar - [x] Live / Paused / Force indicator on Charts toolbar
- [x] Dedicated Fleet tab (multi-host cards, set active / reconnect) - [x] Dedicated Fleet tab (multi-host cards, set active / reconnect)
@@ -128,10 +128,18 @@ Do not name third-party products in code, commits, or user-facing copy.
--- ---
## Exit criteria (P0) ## Exit criteria (P0) — met
1. Charts tab shows **all** catalog charts in collapsible sections. 1. Charts tab shows **all** catalog charts in collapsible sections.
2. TOC jumps scroll the wall; search filters TOC + wall. 2. TOC jumps scroll the wall; search filters TOC + wall.
3. Play/pause + time preset change refetch visible cards. 3. Play/pause + time preset change refetch visible cards.
4. Multi-dimension charts draw every series (not a single scalar). 4. Multi-dimension charts draw every series (not a single scalar).
5. Overview remains unchanged as the compact home grid. 5. Overview remains the compact home grid.
## Desktop polish (post Phase 6)
- Full-width metrics grid (`auto-fit`), single wall scrollbar, capped related panel
- Series fill plot width (X domain on series length); light X time ticks
- Legacy single-chart detail panel removed; dblclick expands stats + related
- Hover pad shared with Y-axis; hero-aware resize; hover paint batched via rAF
- Fleet cards show CPU/RAM chips when available; empty → Connect CTA
+2 -1
View File
@@ -110,7 +110,7 @@ Template rebrand, protocol, collector, memory store, anomalies, REST, desktop MV
--- ---
## Phase 6 — Master metrics dashboard (Charts tab) ← **active** ## Phase 6 — Master metrics dashboard (Charts tab) ← **done + polish**
Turn Charts from a single-chart catalog into a full metrics wall (taxonomy TOC, Turn Charts from a single-chart catalog into a full metrics wall (taxonomy TOC,
shared time, multi-dim cards, investigation). Detail checklist: shared time, multi-dim cards, investigation). Detail checklist:
@@ -132,6 +132,7 @@ shared time, multi-dim cards, investigation). Detail checklist:
| Board mode (pinned-only wall) | Done | | Board mode (pinned-only wall) | Done |
| Drag-reorder pins + keyboard shortcuts | Done | | Drag-reorder pins + keyboard shortcuts | Done |
| Taxonomy coverage test + EXTENDING note | Done | | Taxonomy coverage test + EXTENDING note | Done |
| Desktop polish (layout fill, plot domain, legacy detail removed, Fleet chips) | Done |
| Multi-named custom boards | Later | | 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
+2 -13
View File
@@ -221,18 +221,9 @@
<aside class="metrics-toc"> <aside class="metrics-toc">
<input id="chart-search" type="search" placeholder="Filter charts…" autocomplete="off" /> <input id="chart-search" type="search" placeholder="Filter charts…" autocomplete="off" />
<nav id="metrics-toc" class="metrics-toc-nav" aria-label="Chart sections"></nav> <nav id="metrics-toc" class="metrics-toc-nav" aria-label="Chart sections"></nav>
<ul id="chart-catalog-list" class="catalog-list hidden" hidden></ul>
</aside> </aside>
<div id="metrics-wall" class="metrics-wall" tabindex="0"></div> <div id="metrics-wall" class="metrics-wall" tabindex="0"></div>
</div> </div>
<article id="chart-detail-panel" class="chart-detail metrics-detail hidden">
<header>
<h3 id="chart-detail-title">Select a chart</h3>
<span id="chart-detail-meta" class="muted"></span>
</header>
<canvas id="chart-detail" height="220"></canvas>
<pre id="chart-detail-dims" class="dim-list muted"></pre>
</article>
</section> </section>
<!-- Alerts --> <!-- Alerts -->
@@ -278,9 +269,6 @@
</section> </section>
<div id="fleet-cards" class="fleet-cards"></div> <div id="fleet-cards" class="fleet-cards"></div>
<pre id="invite-out" class="invite-out hidden"></pre> <pre id="invite-out" class="invite-out hidden"></pre>
<!-- Compatibility hooks for legacy list renderers -->
<ul id="bookmark-list" class="hidden" hidden></ul>
<ul id="peer-list" class="hidden" hidden></ul>
</section> </section>
<!-- Connect --> <!-- Connect -->
@@ -361,9 +349,10 @@
<div class="dash-card settings-section"> <div class="dash-card settings-section">
<h3>History window</h3> <h3>History window</h3>
<label> <label>
Points per chart Overview spark depth
<input id="setting-chart-points" type="number" min="30" max="300" step="10" value="90" /> <input id="setting-chart-points" type="number" min="30" max="300" step="10" value="90" />
</label> </label>
<p class="muted settings-hint">Fallback ring size when Charts has no active window. While Charts is open, Overview follows the Charts timeframe (capped at 300).</p>
<label> <label>
Default spotlight chart Default spotlight chart
<input id="setting-default-explore" type="text" value="system.io" /> <input id="setting-default-explore" type="text" value="system.io" />
+7
View File
@@ -6,6 +6,7 @@ import {
normalizeChartMode, normalizeChartMode,
isCompositionChart, isCompositionChart,
} from '../shared/chart-types.js' } from '../shared/chart-types.js'
import { padLeftFor } from '../ui/charts.js'
test('normalizeChartMode aliases', (t) => { test('normalizeChartMode aliases', (t) => {
t.is(normalizeChartMode('STACK'), 'stacked') t.is(normalizeChartMode('STACK'), 'stacked')
@@ -45,3 +46,9 @@ test('line charts cycle through sensible modes', (t) => {
t.ok(seen.has('bar')) t.ok(seen.has('bar'))
t.absent(seen.has('pie')) t.absent(seen.has('pie'))
}) })
test('padLeftFor matches Y-axis layout', (t) => {
t.is(padLeftFor('', true), 46)
t.is(padLeftFor('kilobytes', true), 52)
t.is(padLeftFor('percentage', false), 0)
})
+98 -37
View File
@@ -19,6 +19,8 @@ import { normalizeChartMode } from '../shared/chart-types.js'
* units?: string, * units?: string,
* emptyMessage?: string, * emptyMessage?: string,
* dimmed?: boolean, * dimmed?: boolean,
* windowSeconds?: number|null,
* endOffset?: number,
* }} [opts] * }} [opts]
*/ */
export function drawChart(canvas, lines, opts = {}) { export function drawChart(canvas, lines, opts = {}) {
@@ -40,6 +42,17 @@ export function drawChart(canvas, lines, opts = {}) {
* @param {Array<{ values: number[], color: string, label?: string, fill?: boolean, hidden?: boolean }>} lines * @param {Array<{ values: number[], color: string, label?: string, fill?: boolean, hidden?: boolean }>} lines
* @param {object} [opts] * @param {object} [opts]
*/ */
/**
* Shared Y-axis left padding — keep paint + hover hit-test in sync.
* @param {string|undefined|null} units
* @param {boolean} [showYAxis=true]
*/
export function padLeftFor(units, showYAxis = true) {
if (showYAxis === false) return 0
const u = units ? String(units) : ''
return u.length > 6 ? 52 : 46
}
export function drawMultiChart(canvas, lines, opts = {}) { export function drawMultiChart(canvas, lines, opts = {}) {
if (!canvas) return if (!canvas) return
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
@@ -55,16 +68,18 @@ export function drawMultiChart(canvas, lines, opts = {}) {
const mode = normalizeChartMode(opts.mode || (opts.stacked ? 'stacked' : 'line')) const mode = normalizeChartMode(opts.mode || (opts.stacked ? 'stacked' : 'line'))
const maxPoints = opts.maxPoints || 90 const maxPoints = opts.maxPoints || 90
const units = opts.units ? String(opts.units) : '' const units = opts.units ? String(opts.units) : ''
const padL = opts.padLeft ?? (opts.showYAxis !== false ? (units.length > 6 ? 52 : 46) : 0) const showY = opts.showYAxis !== false
const padL = opts.padLeft ?? padLeftFor(units, showY)
const padR = 10 const padR = 10
const padT = 12 const padT = 12
const padB = 10 const showX = opts.windowSeconds != null && Number(opts.windowSeconds) > 0
const padB = showX ? 18 : 10
const plotW = Math.max(1, w - padL - padR) const plotW = Math.max(1, w - padL - padR)
const plotH = Math.max(1, h - padT - padB) const plotH = Math.max(1, h - padT - padB)
const prepared = prepareSeries(lines, maxPoints) const prepared = prepareSeries(lines, maxPoints)
if (!prepared.length) { if (!prepared.length) {
drawEmpty(ctx, padL, padT, opts.emptyMessage) drawEmpty(ctx, w, h, opts.emptyMessage)
return return
} }
@@ -72,18 +87,21 @@ export function drawMultiChart(canvas, lines, opts = {}) {
const stacked = mode === 'stacked' || opts.stacked === true const stacked = mode === 'stacked' || opts.stacked === true
const { min, max, span } = computeScale(prepared, { stacked, threshold: opts.threshold }) const { min, max, span } = computeScale(prepared, { stacked, threshold: opts.threshold })
const xAt = (i, len) => padL + (i / Math.max(1, Math.max(maxPoints, len) - 1)) * plotW // Domain X on actual series length so short rings fill the plot width
const len = Math.max(1, ...prepared.map((l) => l.values.length))
const xAt = (i) => padL + (i / Math.max(1, len - 1)) * plotW
const yAt = (v) => padT + plotH - ((v - min) / span) * plotH const yAt = (v) => padT + plotH - ((v - min) / span) * plotH
drawGrid(ctx, padL, padT, plotW, plotH, min, max, yAt) drawGrid(ctx, padL, padT, plotW, plotH, min, max, yAt)
if (opts.showYAxis !== false) drawYAxis(ctx, padL, padT, plotH, min, max, span, units) if (showY) drawYAxis(ctx, padL, padT, plotH, min, max, span, units)
if (showX) drawXAxis(ctx, padL, padT, plotW, plotH, opts)
if (opts.threshold != null && Number.isFinite(opts.threshold)) { if (opts.threshold != null && Number.isFinite(opts.threshold)) {
drawThreshold(ctx, padL, plotW, yAt(opts.threshold), opts.severity) drawThreshold(ctx, padL, plotW, yAt(opts.threshold), opts.severity)
} }
if (stacked) { if (stacked) {
drawStacked(ctx, prepared, xAt, yAt, maxPoints) drawStacked(ctx, prepared, xAt, yAt)
} else { } else {
prepared.forEach((line) => { prepared.forEach((line) => {
const wantFill = mode === 'area' ? line.fill !== false : line.fill === true const wantFill = mode === 'area' ? line.fill !== false : line.fill === true
@@ -92,7 +110,7 @@ export function drawMultiChart(canvas, lines, opts = {}) {
} }
if (opts.hoverIndex != null && Number.isFinite(opts.hoverIndex)) { if (opts.hoverIndex != null && Number.isFinite(opts.hoverIndex)) {
drawHoverCrosshair(ctx, prepared, opts.hoverIndex, xAt, yAt, padT, plotH, maxPoints) drawHoverCrosshair(ctx, prepared, opts.hoverIndex, xAt, yAt, padT, plotH)
} }
ctx.globalAlpha = 1 ctx.globalAlpha = 1
} }
@@ -117,10 +135,11 @@ export function drawBarChart(canvas, lines, opts = {}) {
const maxPoints = opts.maxPoints || 90 const maxPoints = opts.maxPoints || 90
const units = opts.units ? String(opts.units) : '' const units = opts.units ? String(opts.units) : ''
const padL = opts.padLeft ?? (units.length > 6 ? 52 : 46) const padL = opts.padLeft ?? padLeftFor(units, true)
const padR = 10 const padR = 10
const padT = 12 const padT = 12
const padB = 10 const showX = opts.windowSeconds != null && Number(opts.windowSeconds) > 0
const padB = showX ? 18 : 10
const plotW = Math.max(1, w - padL - padR) const plotW = Math.max(1, w - padL - padR)
const plotH = Math.max(1, h - padT - padB) const plotH = Math.max(1, h - padT - padB)
const grouped = Boolean(opts.grouped) const grouped = Boolean(opts.grouped)
@@ -133,7 +152,7 @@ export function drawBarChart(canvas, lines, opts = {}) {
.slice(0, 6) .slice(0, 6)
} }
if (!prepared.length) { if (!prepared.length) {
drawEmpty(ctx, padL, padT, opts.emptyMessage) drawEmpty(ctx, w, h, opts.emptyMessage)
return return
} }
@@ -150,6 +169,7 @@ export function drawBarChart(canvas, lines, opts = {}) {
drawGrid(ctx, padL, padT, plotW, plotH, min, max, yAt) drawGrid(ctx, padL, padT, plotW, plotH, min, max, yAt)
drawYAxis(ctx, padL, padT, plotH, min, max, span, units) drawYAxis(ctx, padL, padT, plotH, min, max, span, units)
if (showX) drawXAxis(ctx, padL, padT, plotW, plotH, opts)
const slot = plotW / Math.max(1, len) const slot = plotW / Math.max(1, len)
const groupCount = grouped ? prepared.length : 1 const groupCount = grouped ? prepared.length : 1
@@ -228,12 +248,17 @@ export function drawPieChart(canvas, lines, opts = {}) {
.sort((a, b) => b.value - a.value) .sort((a, b) => b.value - a.value)
if (!slices.length) { if (!slices.length) {
drawEmpty(ctx, 12, 16, opts.emptyMessage || 'No data') drawEmpty(ctx, w, h, opts.emptyMessage || 'No data')
return return
} }
if (opts.dimmed) ctx.globalAlpha = 0.72 if (opts.dimmed) ctx.globalAlpha = 0.72
const light = isLightTheme()
const ink = light ? 'rgba(15, 23, 42, 0.88)' : 'rgba(244, 247, 251, 0.9)'
const muted = light ? 'rgba(71, 85, 105, 0.85)' : 'rgba(154, 168, 188, 0.85)'
const legendInk = light ? 'rgba(30, 41, 59, 0.92)' : 'rgba(200, 209, 223, 0.92)'
const total = slices.reduce((s, x) => s + x.value, 0) || 1 const total = slices.reduce((s, x) => s + x.value, 0) || 1
const cx = w * 0.38 const cx = w * 0.38
const cy = h / 2 const cy = h / 2
@@ -253,13 +278,13 @@ export function drawPieChart(canvas, lines, opts = {}) {
} }
// Center total // Center total
ctx.fillStyle = 'rgba(244, 247, 251, 0.9)' ctx.fillStyle = ink
ctx.font = '600 12px "Outfit", ui-sans-serif, system-ui, sans-serif' ctx.font = '600 12px "Outfit", ui-sans-serif, system-ui, sans-serif'
ctx.textAlign = 'center' ctx.textAlign = 'center'
ctx.textBaseline = 'middle' ctx.textBaseline = 'middle'
ctx.fillText(formatAxis(total), cx, cy - 6) ctx.fillText(formatAxis(total), cx, cy - 6)
if (opts.units) { if (opts.units) {
ctx.fillStyle = 'rgba(154, 168, 188, 0.85)' ctx.fillStyle = muted
ctx.font = '9px "IBM Plex Mono", ui-monospace, Menlo, monospace' ctx.font = '9px "IBM Plex Mono", ui-monospace, Menlo, monospace'
ctx.fillText(String(opts.units).slice(0, 12), cx, cy + 10) ctx.fillText(String(opts.units).slice(0, 12), cx, cy + 10)
} }
@@ -275,7 +300,7 @@ export function drawPieChart(canvas, lines, opts = {}) {
ctx.beginPath() ctx.beginPath()
ctx.arc(lx, ly, 3.5, 0, Math.PI * 2) ctx.arc(lx, ly, 3.5, 0, Math.PI * 2)
ctx.fill() ctx.fill()
ctx.fillStyle = 'rgba(200, 209, 223, 0.92)' ctx.fillStyle = legendInk
ctx.font = '11px "IBM Plex Mono", ui-monospace, Menlo, monospace' ctx.font = '11px "IBM Plex Mono", ui-monospace, Menlo, monospace'
const name = slice.label.length > 14 ? slice.label.slice(0, 13) + '…' : slice.label const name = slice.label.length > 14 ? slice.label.slice(0, 13) + '…' : slice.label
ctx.fillText(`${name} ${pct}%`, lx + 10, ly) ctx.fillText(`${name} ${pct}%`, lx + 10, ly)
@@ -370,7 +395,7 @@ function drawThreshold(ctx, padL, plotW, y, severity) {
ctx.setLineDash([]) ctx.setLineDash([])
} }
function drawStacked(ctx, prepared, xAt, yAt, maxPoints) { function drawStacked(ctx, prepared, xAt, yAt) {
const len = Math.max(...prepared.map((l) => l.values.length)) const len = Math.max(...prepared.map((l) => l.values.length))
/** @type {number[]} */ /** @type {number[]} */
const acc = new Array(len).fill(0) const acc = new Array(len).fill(0)
@@ -378,13 +403,13 @@ function drawStacked(ctx, prepared, xAt, yAt, maxPoints) {
ctx.beginPath() ctx.beginPath()
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
const v = (line.values[i] ?? 0) + acc[i] const v = (line.values[i] ?? 0) + acc[i]
const x = xAt(i, Math.max(maxPoints, len)) const x = xAt(i)
const y = yAt(v) const y = yAt(v)
if (i === 0) ctx.moveTo(x, y) if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y) else ctx.lineTo(x, y)
} }
for (let i = len - 1; i >= 0; i--) { for (let i = len - 1; i >= 0; i--) {
ctx.lineTo(xAt(i, Math.max(maxPoints, len)), yAt(acc[i])) ctx.lineTo(xAt(i), yAt(acc[i]))
} }
ctx.closePath() ctx.closePath()
ctx.fillStyle = withAlpha(line.color, 0.5) ctx.fillStyle = withAlpha(line.color, 0.5)
@@ -392,7 +417,7 @@ function drawStacked(ctx, prepared, xAt, yAt, maxPoints) {
ctx.beginPath() ctx.beginPath()
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
const v = (line.values[i] ?? 0) + acc[i] const v = (line.values[i] ?? 0) + acc[i]
const x = xAt(i, Math.max(maxPoints, len)) const x = xAt(i)
const y = yAt(v) const y = yAt(v)
if (i === 0) ctx.moveTo(x, y) if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y) else ctx.lineTo(x, y)
@@ -413,13 +438,13 @@ function drawLineSeries(ctx, line, xAt, yAt, padT, plotH, wantFill) {
grad.addColorStop(1, withAlpha(line.color, 0.02)) grad.addColorStop(1, withAlpha(line.color, 0.02))
ctx.beginPath() ctx.beginPath()
values.forEach((v, i) => { values.forEach((v, i) => {
const x = xAt(i, len) const x = xAt(i)
const y = yAt(v) const y = yAt(v)
if (i === 0) ctx.moveTo(x, y) if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y) else ctx.lineTo(x, y)
}) })
ctx.lineTo(xAt(len - 1, len), padT + plotH) ctx.lineTo(xAt(len - 1), padT + plotH)
ctx.lineTo(xAt(0, len), padT + plotH) ctx.lineTo(xAt(0), padT + plotH)
ctx.closePath() ctx.closePath()
ctx.fillStyle = grad ctx.fillStyle = grad
ctx.fill() ctx.fill()
@@ -430,7 +455,7 @@ function drawLineSeries(ctx, line, xAt, yAt, padT, plotH, wantFill) {
ctx.lineCap = 'round' ctx.lineCap = 'round'
ctx.beginPath() ctx.beginPath()
values.forEach((v, i) => { values.forEach((v, i) => {
const x = xAt(i, len) const x = xAt(i)
const y = yAt(v) const y = yAt(v)
if (i === 0) ctx.moveTo(x, y) if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y) else ctx.lineTo(x, y)
@@ -438,10 +463,10 @@ function drawLineSeries(ctx, line, xAt, yAt, padT, plotH, wantFill) {
ctx.stroke() ctx.stroke()
} }
function drawHoverCrosshair(ctx, prepared, hoverIndex, xAt, yAt, padT, plotH, maxPoints) { function drawHoverCrosshair(ctx, prepared, hoverIndex, xAt, yAt, padT, plotH) {
const len = Math.max(...prepared.map((l) => l.values.length)) const len = Math.max(...prepared.map((l) => l.values.length))
const idx = clampIdx(hoverIndex, len) const idx = clampIdx(hoverIndex, len)
const x = xAt(idx, Math.max(maxPoints, len)) const x = xAt(idx)
ctx.strokeStyle = 'rgba(226, 232, 240, 0.4)' ctx.strokeStyle = 'rgba(226, 232, 240, 0.4)'
ctx.lineWidth = 1 ctx.lineWidth = 1
ctx.beginPath() ctx.beginPath()
@@ -453,7 +478,7 @@ function drawHoverCrosshair(ctx, prepared, hoverIndex, xAt, yAt, padT, plotH, ma
if (v == null) continue if (v == null) continue
const y = yAt(v) const y = yAt(v)
ctx.beginPath() ctx.beginPath()
ctx.fillStyle = 'rgba(10, 12, 16, 0.85)' ctx.fillStyle = isLightTheme() ? 'rgba(255, 255, 255, 0.9)' : 'rgba(10, 12, 16, 0.85)'
ctx.arc(x, y, 5, 0, Math.PI * 2) ctx.arc(x, y, 5, 0, Math.PI * 2)
ctx.fill() ctx.fill()
ctx.fillStyle = line.color ctx.fillStyle = line.color
@@ -463,14 +488,51 @@ function drawHoverCrosshair(ctx, prepared, hoverIndex, xAt, yAt, padT, plotH, ma
} }
} }
function drawEmpty(ctx, padL, padT, message) { function drawXAxis(ctx, padL, padT, plotW, plotH, opts) {
ctx.fillStyle = 'rgba(154, 168, 188, 0.5)' const windowSec = Number(opts.windowSeconds) || 0
if (windowSec <= 0) return
const endOffset = Math.max(0, Number(opts.endOffset) || 0)
const ticks = 3
const ink = isLightTheme() ? 'rgba(100, 116, 139, 0.75)' : 'rgba(154, 168, 188, 0.55)'
ctx.fillStyle = ink
ctx.font = '9px "IBM Plex Mono", ui-monospace, Menlo, monospace'
ctx.textBaseline = 'top'
for (let t = 0; t <= ticks; t++) {
const frac = t / ticks
const x = padL + frac * plotW
const age = endOffset + windowSec * (1 - frac)
const label = age < 1.5 ? 'now' : `-${formatDurShort(age)}`
ctx.textAlign = t === 0 ? 'left' : t === ticks ? 'right' : 'center'
ctx.fillText(label, x, padT + plotH + 2)
}
}
function drawEmpty(ctx, w, h, message) {
ctx.fillStyle = isLightTheme() ? 'rgba(100, 116, 139, 0.65)' : 'rgba(154, 168, 188, 0.55)'
ctx.font = '12px "Outfit", ui-sans-serif, system-ui, sans-serif' ctx.font = '12px "Outfit", ui-sans-serif, system-ui, sans-serif'
String(message || 'No data') ctx.textAlign = 'center'
.split('\n') ctx.textBaseline = 'middle'
.forEach((line, i) => { const lines = String(message || 'No data').split('\n')
ctx.fillText(line, padL + 8, padT + 18 + i * 16) const startY = h / 2 - ((lines.length - 1) * 16) / 2
}) lines.forEach((line, i) => {
ctx.fillText(line, w / 2, startY + i * 16)
})
}
function isLightTheme() {
try {
return document.documentElement?.getAttribute('data-theme') === 'light'
} catch {
return false
}
}
function formatDurShort(sec) {
const s = Math.max(0, Number(sec) || 0)
if (s < 60) return `${Math.round(s)}s`
if (s < 3600) return `${Math.round(s / 60)}m`
if (s < 86400) return `${(s / 3600).toFixed(s >= 10 * 3600 ? 0 : 1)}h`
return `${(s / 86400).toFixed(1)}d`
} }
function roundRect(ctx, x, y, w, h, r) { function roundRect(ctx, x, y, w, h, r) {
@@ -497,19 +559,18 @@ function clampIdx(idx, len) {
* Map pointer x → series index for a canvas using the same layout as drawMultiChart. * Map pointer x → series index for a canvas using the same layout as drawMultiChart.
* @param {HTMLCanvasElement} canvas * @param {HTMLCanvasElement} canvas
* @param {number} clientX * @param {number} clientX
* @param {{ maxPoints?: number, padLeft?: number, showYAxis?: boolean, seriesLen?: number }} [opts] * @param {{ padLeft?: number, showYAxis?: boolean, seriesLen?: number, units?: string }} [opts]
*/ */
export function hoverIndexFromEvent(canvas, clientX, opts = {}) { export function hoverIndexFromEvent(canvas, clientX, opts = {}) {
const rect = canvas.getBoundingClientRect() const rect = canvas.getBoundingClientRect()
const w = rect.width || canvas.clientWidth || 320 const w = rect.width || canvas.clientWidth || 320
const padL = opts.padLeft ?? (opts.showYAxis !== false ? 46 : 0) const padL = opts.padLeft ?? padLeftFor(opts.units, opts.showYAxis !== false)
const padR = 10 const padR = 10
const plotW = Math.max(1, w - padL - padR) const plotW = Math.max(1, w - padL - padR)
const x = clientX - rect.left - padL const x = clientX - rect.left - padL
if (x < 0 || x > plotW) return null if (x < 0 || x > plotW) return null
const maxPoints = opts.maxPoints || 90 const len = Math.max(2, opts.seriesLen || 2)
const len = Math.max(2, opts.seriesLen || maxPoints) const idx = Math.round((x / plotW) * (len - 1))
const idx = Math.round((x / plotW) * (Math.max(maxPoints, len) - 1))
return Math.max(0, Math.min(len - 1, idx)) return Math.max(0, Math.min(len - 1, idx))
} }
+58 -26
View File
@@ -10,7 +10,7 @@ import {
nextChartMode, nextChartMode,
normalizeChartMode, normalizeChartMode,
} from '../shared/chart-types.js' } from '../shared/chart-types.js'
import { drawChart, hoverIndexFromEvent, pushDim, seriesColor } from './charts.js' import { drawChart, hoverIndexFromEvent, padLeftFor, pushDim, seriesColor } from './charts.js'
const GROUPS = ['average', 'min', 'max', 'sum'] const GROUPS = ['average', 'min', 'max', 'sum']
@@ -64,7 +64,7 @@ function smoothScrollIntoView(el, block = 'start') {
* getCatalog: () => Record<string, object>, * getCatalog: () => Record<string, object>,
* queryData: (args: object) => Promise<object>, * queryData: (args: object) => Promise<object>,
* getPoints: () => number, * getPoints: () => number,
* onSelectChart?: (id: string) => void, * isConnected?: () => boolean,
* getPrefs?: () => { * getPrefs?: () => {
* cardHeight?: number, * cardHeight?: number,
* dimSort?: 'name'|'value', * dimSort?: 'name'|'value',
@@ -116,6 +116,24 @@ export function createMetricsDashboard(opts) {
fetchGen: new Map(), fetchGen: new Map(),
/** Seconds of history available across catalog (0 = unknown). */ /** Seconds of history available across catalog (0 = unknown). */
retentionSeconds: 0, retentionSeconds: 0,
hoverSeriesLen: 0,
hoverPaintRaf: 0,
}
function scheduleHoverPaint() {
if (state.hoverPaintRaf) return
state.hoverPaintRaf = requestAnimationFrame(() => {
state.hoverPaintRaf = 0
for (const id of state.visible) paintCard(id)
})
}
function seriesLenForCard(chartId) {
const card = state.cards.get(chartId)
if (!card) return maxPoints()
let n = 0
for (const arr of card.dims.values()) n = Math.max(n, arr?.length || 0)
return n > 1 ? n : maxPoints()
} }
function points() { function points() {
@@ -250,11 +268,8 @@ export function createMetricsDashboard(opts) {
} }
function presetAvailable(seconds) { function presetAvailable(seconds) {
const catalog = opts.getCatalog() || {} // Always selectable — empty wall / disconnected shows idle state.
// Only hard-disable when nothing is connected / no catalog yet // Retention is advisory in the hint, not a hard lock.
if (!Object.keys(catalog).length) return false
// Once connected, all presets are selectable. Warm/live query fills what it can;
// retention is advisory in the hint, not a hard lock.
void seconds void seconds
return true return true
} }
@@ -281,25 +296,25 @@ export function createMetricsDashboard(opts) {
if (!opts.els.presets) return if (!opts.els.presets) return
const retention = computeRetentionSeconds() const retention = computeRetentionSeconds()
const catalogEmpty = !Object.keys(opts.getCatalog() || {}).length const catalogEmpty = !Object.keys(opts.getCatalog() || {}).length
const connected = Boolean(opts.isConnected?.() ?? !catalogEmpty)
opts.els.presets.querySelectorAll('[data-preset]').forEach((btn) => { opts.els.presets.querySelectorAll('[data-preset]').forEach((btn) => {
const id = btn.getAttribute('data-preset') || '' const id = btn.getAttribute('data-preset') || ''
const p = TIME_PRESETS.find((x) => x.id === id) const p = TIME_PRESETS.find((x) => x.id === id)
const seconds = p?.seconds || 0 const seconds = p?.seconds || 0
const ok = !catalogEmpty && presetAvailable(seconds) const thin = connected && !catalogEmpty && retention > 0 && retention + 30 < seconds
const thin = !catalogEmpty && retention > 0 && retention + 30 < seconds
btn.classList.toggle('active', id === state.presetId) btn.classList.toggle('active', id === state.presetId)
btn.classList.toggle('thin-history', thin) btn.classList.toggle('thin-history', thin)
btn.disabled = !ok btn.disabled = false
btn.title = !ok btn.title = !connected
? 'Connect an agent to load history' ? `Last ${id} · connect an agent to load samples`
: thin : thin
? `Last ${id} · ~${formatDuration(retention)} buffered (may be sparse)` ? `Last ${id} · ~${formatDuration(retention)} buffered (may be sparse)`
: `Last ${id}` : `Last ${id}`
}) })
const hint = opts.els.retentionHint const hint = opts.els.retentionHint
if (hint) { if (hint) {
if (catalogEmpty) { if (!connected || catalogEmpty) {
hint.textContent = 'Connect an agent to enable time windows.' hint.textContent = 'Connect an agent to populate charts for the selected window.'
hint.classList.remove('hidden') hint.classList.remove('hidden')
} else if (retention > 0 && retention < state.afterSeconds) { } else if (retention > 0 && retention < state.afterSeconds) {
hint.textContent = `Buffered history ~${formatDuration(retention)} — longer windows may look sparse until more samples arrive.` hint.textContent = `Buffered history ~${formatDuration(retention)} — longer windows may look sparse until more samples arrive.`
@@ -534,16 +549,20 @@ export function createMetricsDashboard(opts) {
? card.emptyReason || 'No data' ? card.emptyReason || 'No data'
: 'No data' : 'No data'
const units = card.meta?.units || '' const units = card.meta?.units || ''
const showY = mode !== 'pie'
drawChart(canvas, lines, { drawChart(canvas, lines, {
mode, mode,
maxPoints: maxPoints(), maxPoints: maxPoints(),
showYAxis: mode !== 'pie', showYAxis: showY,
padLeft: padLeftFor(units, showY),
hoverIndex: mode === 'pie' ? null : state.hoverIndex, hoverIndex: mode === 'pie' ? null : state.hoverIndex,
threshold: mode === 'pie' ? null : anomaly?.threshold ?? null, threshold: mode === 'pie' ? null : anomaly?.threshold ?? null,
severity: anomaly?.severity || null, severity: anomaly?.severity || null,
emptyMessage: emptyMsg, emptyMessage: emptyMsg,
dimmed: Boolean(card.updating), dimmed: Boolean(card.updating),
units, units,
windowSeconds: mode === 'pie' ? null : state.afterSeconds,
endOffset: state.endOffset,
}) })
updateCardTooltip(article, id, card, lines, units) updateCardTooltip(article, id, card, lines, units)
const statsEl = article.querySelector('.metric-card-stats') const statsEl = article.querySelector('.metric-card-stats')
@@ -551,7 +570,6 @@ export function createMetricsDashboard(opts) {
statsEl.innerHTML = renderStatsHtml(card, dims, hidden, units) statsEl.innerHTML = renderStatsHtml(card, dims, hidden, units)
} }
if (legend && !card.updating) { if (legend && !card.updating) {
const visibleLines = lines.filter((l) => !l.hidden)
const show = lines.slice(0, LEGEND_CHIP_CAP) const show = lines.slice(0, LEGEND_CHIP_CAP)
const extra = Math.max(0, lines.length - LEGEND_CHIP_CAP) const extra = Math.max(0, lines.length - LEGEND_CHIP_CAP)
const unitSuffix = units ? ` ${units}` : '' const unitSuffix = units ? ` ${units}` : ''
@@ -566,7 +584,6 @@ export function createMetricsDashboard(opts) {
(extra (extra
? `<span class="dim-chip-more muted" title="${extra} more dimensions">+${extra}</span>` ? `<span class="dim-chip-more muted" title="${extra} more dimensions">+${extra}</span>`
: '') : '')
void visibleLines
if (legend.dataset.sig !== nextHtml) { if (legend.dataset.sig !== nextHtml) {
legend.dataset.sig = nextHtml legend.dataset.sig = nextHtml
legend.innerHTML = nextHtml legend.innerHTML = nextHtml
@@ -782,7 +799,8 @@ export function createMetricsDashboard(opts) {
: '' : ''
return return
} }
const frac = maxPoints() > 1 ? state.hoverIndex / (maxPoints() - 1) : 0 const denom = Math.max(1, (state.hoverSeriesLen || maxPoints()) - 1)
const frac = state.hoverIndex / denom
const age = state.endOffset + state.afterSeconds * (1 - frac) const age = state.endOffset + state.afterSeconds * (1 - frac)
opts.els.hoverReadout.textContent = `~${formatDuration(Math.max(0, age))} ago` opts.els.hoverReadout.textContent = `~${formatDuration(Math.max(0, age))} ago`
} }
@@ -792,20 +810,27 @@ export function createMetricsDashboard(opts) {
canvas.addEventListener('mousemove', (ev) => { canvas.addEventListener('mousemove', (ev) => {
if (state.pan?.active) return if (state.pan?.active) return
const card = state.cards.get(chartId)
const seriesLen = seriesLenForCard(chartId)
const units = card?.meta?.units || ''
const showY = normalizeChartMode(card?.mode) !== 'pie'
const idx = hoverIndexFromEvent(canvas, ev.clientX, { const idx = hoverIndexFromEvent(canvas, ev.clientX, {
maxPoints: maxPoints(), showYAxis: showY,
showYAxis: true, seriesLen,
seriesLen: maxPoints(), units,
padLeft: padLeftFor(units, showY),
}) })
state.hoverIndex = idx state.hoverIndex = idx
state.hoverSeriesLen = seriesLen
updateHoverReadout() updateHoverReadout()
for (const id of state.visible) paintCard(id) scheduleHoverPaint()
}) })
canvas.addEventListener('mouseleave', () => { canvas.addEventListener('mouseleave', () => {
if (state.pan?.active) return if (state.pan?.active) return
state.hoverIndex = null state.hoverIndex = null
state.hoverSeriesLen = 0
updateHoverReadout() updateHoverReadout()
for (const id of state.visible) paintCard(id) scheduleHoverPaint()
}) })
// Natural wall scroll by default. Zoom only with an intentional modifier // Natural wall scroll by default. Zoom only with an intentional modifier
@@ -1207,7 +1232,14 @@ export function createMetricsDashboard(opts) {
}) })
article.addEventListener('dblclick', (ev) => { article.addEventListener('dblclick', (ev) => {
if (ev.target instanceof HTMLElement && ev.target.closest('.metric-card-actions')) return if (ev.target instanceof HTMLElement && ev.target.closest('.metric-card-actions')) return
opts.onSelectChart?.(id) article.classList.add('expanded')
const statsEl = article.querySelector('.metric-card-stats')
if (statsEl) {
statsEl.classList.remove('hidden')
const { dims, hidden } = sortedDims(card, id)
statsEl.innerHTML = renderStatsHtml(card, dims, hidden, card.meta?.units || '')
}
showRelated(id)
}) })
state.cardEls.set(id, article) state.cardEls.set(id, article)
state.observer?.observe(article) state.observer?.observe(article)
@@ -1221,9 +1253,9 @@ export function createMetricsDashboard(opts) {
let startH = 0 let startH = 0
const onMove = (ev) => { const onMove = (ev) => {
state.cardHeight = clampHeight(startH + (ev.clientY - startY)) state.cardHeight = clampHeight(startH + (ev.clientY - startY))
for (const el of state.cardEls.values()) { for (const [cid, el] of state.cardEls) {
const c = el.querySelector('canvas') const c = el.querySelector('canvas')
if (c) c.style.height = `${state.cardHeight}px` if (c) c.style.height = `${cardHeightFor(cid, state.cards.get(cid))}px`
} }
for (const id of state.visible) paintCard(id) for (const id of state.visible) paintCard(id)
} }
+36
View File
@@ -17,6 +17,9 @@
* capability: string|null, * capability: string|null,
* adminSeed: string|null, * adminSeed: string|null,
* lastConnectedAt: number|null, * lastConnectedAt: number|null,
* cpu?: number|null,
* ram?: number|null,
* health?: string|null,
* }} FleetPeer * }} FleetPeer
*/ */
@@ -131,6 +134,7 @@ export function summarizeFleet(roster) {
* onForget: (peer: FleetPeer) => void|Promise<void>, * onForget: (peer: FleetPeer) => void|Promise<void>,
* onOpenCharts: (peer: FleetPeer) => void|Promise<void>, * onOpenCharts: (peer: FleetPeer) => void|Promise<void>,
* onAlias: (peer: FleetPeer) => void|Promise<void>, * onAlias: (peer: FleetPeer) => void|Promise<void>,
* onConnect?: () => void,
* }} handlers * }} handlers
*/ */
export function renderFleetCards(root, roster, handlers) { export function renderFleetCards(root, roster, handlers) {
@@ -143,7 +147,17 @@ export function renderFleetCards(root, roster, handlers) {
empty.innerHTML = ` empty.innerHTML = `
<p class="fleet-empty-title">No agents yet</p> <p class="fleet-empty-title">No agents yet</p>
<p class="muted">Connect a public key or <code>pd1.</code> invite — saved agents appear here and restore on launch.</p> <p class="muted">Connect a public key or <code>pd1.</code> invite — saved agents appear here and restore on launch.</p>
<p class="fleet-empty-actions"></p>
` `
const actions = empty.querySelector('.fleet-empty-actions')
if (actions && handlers.onConnect) {
const go = document.createElement('button')
go.type = 'button'
go.className = 'btn btn-primary'
go.textContent = 'Connect an agent'
go.addEventListener('click', () => handlers.onConnect?.())
actions.appendChild(go)
}
root.appendChild(empty) root.appendChild(empty)
return return
} }
@@ -178,6 +192,8 @@ function buildCard(peer, handlers) {
const badge = badgeFor(state, peer) const badge = badgeFor(state, peer)
const health = healthLine(peer, state) const health = healthLine(peer, state)
const chips = metricChips(peer)
card.innerHTML = ` card.innerHTML = `
<header class="fleet-card-head"> <header class="fleet-card-head">
<div class="fleet-card-titles"> <div class="fleet-card-titles">
@@ -186,6 +202,7 @@ function buildCard(peer, handlers) {
</div> </div>
<span class="fleet-badge fleet-badge--${state}">${escapeHtml(badge)}</span> <span class="fleet-badge fleet-badge--${state}">${escapeHtml(badge)}</span>
</header> </header>
${chips}
<ul class="fleet-card-meta"> <ul class="fleet-card-meta">
<li><span class="muted">Status</span><strong>${escapeHtml(health)}</strong></li> <li><span class="muted">Status</span><strong>${escapeHtml(health)}</strong></li>
<li><span class="muted">Role</span><strong>${peer.active ? 'Active' : peer.connected ? 'Standby' : '—'}</strong></li> <li><span class="muted">Role</span><strong>${peer.active ? 'Active' : peer.connected ? 'Standby' : '—'}</strong></li>
@@ -218,12 +235,31 @@ function badgeFor(state, peer) {
} }
function healthLine(peer, state) { function healthLine(peer, state) {
if (peer.health === 'critical') return 'Critical'
if (peer.health === 'degraded') return 'Degraded'
if (state === 'active' || state === 'online') return 'Connected' if (state === 'active' || state === 'online') return 'Connected'
if (state === 'reconnecting') return 'Reconnecting…' if (state === 'reconnecting') return 'Reconnecting…'
if (state === 'failed') return 'Max reconnects reached' if (state === 'failed') return 'Max reconnects reached'
return 'Saved · not connected' return 'Saved · not connected'
} }
/** @param {FleetPeer} peer */
function metricChips(peer) {
if (!peer.connected && peer.cpu == null && peer.ram == null) return ''
const cpu =
peer.cpu != null && Number.isFinite(Number(peer.cpu))
? `${Number(peer.cpu).toFixed(0)}%`
: '—'
const ram =
peer.ram != null && Number.isFinite(Number(peer.ram))
? `${Number(peer.ram).toFixed(0)} MiB`
: '—'
return `<div class="fleet-card-chips" aria-label="Live metrics">
<span class="fleet-chip"><span class="muted">CPU</span><strong>${escapeHtml(cpu)}</strong></span>
<span class="fleet-chip"><span class="muted">RAM</span><strong>${escapeHtml(ram)}</strong></span>
</div>`
}
function formatLastSeen(peer) { function formatLastSeen(peer) {
if (peer.connected) return 'now' if (peer.connected) return 'now'
if (!peer.lastConnectedAt) return '—' if (!peer.lastConnectedAt) return '—'
+92 -125
View File
@@ -159,43 +159,13 @@ html[data-theme='light'] {
--scrollbar-thumb-hover: color-mix(in srgb, var(--accent-primary) 45%, rgba(15, 23, 42, 0.35)); --scrollbar-thumb-hover: color-mix(in srgb, var(--accent-primary) 45%, rgba(15, 23, 42, 0.35));
} }
/* Slim scrollbars on the element that actually scrolls (views / walls — not nested) */ /* Slim scrollbars — shared across walls, TOC, sidebar, fleet, logs */
.view, .view,
.metrics-wall, .metrics-wall,
.metrics-toc-nav { .metrics-toc-nav,
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) transparent;
}
.view::-webkit-scrollbar,
.metrics-wall::-webkit-scrollbar,
.metrics-toc-nav::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.view::-webkit-scrollbar-track,
.metrics-wall::-webkit-scrollbar-track,
.metrics-toc-nav::-webkit-scrollbar-track {
background: transparent;
margin: 4px 0;
}
.view::-webkit-scrollbar-thumb,
.metrics-wall::-webkit-scrollbar-thumb,
.metrics-toc-nav::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 999px;
border: 1px solid transparent;
background-clip: padding-box;
min-height: 40px;
}
.view::-webkit-scrollbar-thumb:hover,
.metrics-wall::-webkit-scrollbar-thumb:hover,
.metrics-toc-nav::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
background-clip: padding-box;
}
.metrics-toc, .metrics-toc,
.metric-card-stats, .metric-card-stats,
.related-panel,
#sidebar, #sidebar,
.fleet-cards, .fleet-cards,
.event-list, .event-list,
@@ -204,39 +174,56 @@ html[data-theme='light'] {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) transparent; scrollbar-color: var(--scrollbar-thumb) transparent;
} }
.view::-webkit-scrollbar,
.metrics-wall::-webkit-scrollbar,
.metrics-toc-nav::-webkit-scrollbar,
.metrics-toc::-webkit-scrollbar, .metrics-toc::-webkit-scrollbar,
.metric-card-stats::-webkit-scrollbar, .metric-card-stats::-webkit-scrollbar,
.related-panel::-webkit-scrollbar,
#sidebar::-webkit-scrollbar, #sidebar::-webkit-scrollbar,
.fleet-cards::-webkit-scrollbar, .fleet-cards::-webkit-scrollbar,
.event-list::-webkit-scrollbar, .event-list::-webkit-scrollbar,
.log::-webkit-scrollbar { .log::-webkit-scrollbar,
width: var(--scrollbar-size); .invite-out::-webkit-scrollbar {
height: var(--scrollbar-size); width: 6px;
height: 6px;
} }
.view::-webkit-scrollbar-track,
.metrics-wall::-webkit-scrollbar-track,
.metrics-toc-nav::-webkit-scrollbar-track,
.metrics-toc::-webkit-scrollbar-track, .metrics-toc::-webkit-scrollbar-track,
.metric-card-stats::-webkit-scrollbar-track, .metric-card-stats::-webkit-scrollbar-track,
.related-panel::-webkit-scrollbar-track,
#sidebar::-webkit-scrollbar-track, #sidebar::-webkit-scrollbar-track,
.fleet-cards::-webkit-scrollbar-track, .fleet-cards::-webkit-scrollbar-track,
.event-list::-webkit-scrollbar-track, .event-list::-webkit-scrollbar-track,
.log::-webkit-scrollbar-track { .log::-webkit-scrollbar-track,
.invite-out::-webkit-scrollbar-track {
background: transparent; background: transparent;
margin: 4px 0;
} }
.view::-webkit-scrollbar-thumb,
.metrics-wall::-webkit-scrollbar-thumb,
.metrics-toc-nav::-webkit-scrollbar-thumb,
.metrics-toc::-webkit-scrollbar-thumb, .metrics-toc::-webkit-scrollbar-thumb,
.metric-card-stats::-webkit-scrollbar-thumb, .metric-card-stats::-webkit-scrollbar-thumb,
.related-panel::-webkit-scrollbar-thumb,
#sidebar::-webkit-scrollbar-thumb, #sidebar::-webkit-scrollbar-thumb,
.fleet-cards::-webkit-scrollbar-thumb, .fleet-cards::-webkit-scrollbar-thumb,
.event-list::-webkit-scrollbar-thumb, .event-list::-webkit-scrollbar-thumb,
.log::-webkit-scrollbar-thumb { .log::-webkit-scrollbar-thumb,
.invite-out::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb); background: var(--scrollbar-thumb);
border-radius: 999px; border-radius: 999px;
border: 1px solid transparent; border: 1px solid transparent;
background-clip: padding-box; background-clip: padding-box;
min-height: 40px;
} }
.view::-webkit-scrollbar-thumb:hover,
#sidebar::-webkit-scrollbar-thumb:hover { .metrics-wall::-webkit-scrollbar-thumb:hover,
.metrics-toc-nav::-webkit-scrollbar-thumb:hover,
#sidebar::-webkit-scrollbar-thumb:hover,
.related-panel::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover); background: var(--scrollbar-thumb-hover);
background-clip: padding-box; background-clip: padding-box;
} }
@@ -550,7 +537,14 @@ body[data-reduce-motion='1'] .view {
#overview-view:not(.hidden) { #overview-view:not(.hidden) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: auto; overflow: hidden;
min-height: 0;
}
@media (max-height: 640px) {
#overview-view:not(.hidden) {
overflow: auto;
}
} }
@keyframes viewIn { @keyframes viewIn {
@@ -802,11 +796,11 @@ body.is-offline .offline-banner:not(.hidden) {
background: rgba(0, 0, 0, 0.18); background: rgba(0, 0, 0, 0.18);
} }
/* Lock overview spark heights — width:100% alone was stretching them huge */ /* Overview sparks grow modestly with viewport height; capped so the pane fits */
#overview-view .chart-panel canvas { #overview-view .chart-panel canvas {
height: 84px !important; height: clamp(72px, 11vh, 118px) !important;
max-height: 84px; max-height: 118px;
flex: 0 0 84px; flex: 0 0 auto;
} }
html[data-theme='light'] .chart-panel canvas { html[data-theme='light'] .chart-panel canvas {
@@ -820,7 +814,7 @@ html[data-theme='light'] .chart-panel canvas {
margin-top: 4px; margin-top: 4px;
font-size: 10px; font-size: 10px;
color: var(--text-muted); color: var(--text-muted);
max-height: 22px; max-height: 28px;
overflow: hidden; overflow: hidden;
} }
@@ -1146,6 +1140,15 @@ button.metrics-tf.thin-history:not(.active) {
.related-panel { .related-panel {
margin-bottom: var(--space); margin-bottom: var(--space);
padding: 12px 14px; padding: 12px 14px;
max-height: min(160px, 22vh);
overflow: auto;
flex-shrink: 0;
}
.related-panel:not(.hidden) {
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-secondary);
} }
.related-head { .related-head {
@@ -1414,10 +1417,12 @@ button.metrics-tf.thin-history:not(.active) {
} }
.metric-type-btn { .metric-type-btn {
text-transform: lowercase;
min-width: 3.4rem !important; min-width: 3.4rem !important;
font-family: var(--font-mono) !important; font-family: var(--font-mono) !important;
font-size: 11px !important; font-size: 10px !important;
padding: 2px 8px !important;
text-transform: lowercase;
letter-spacing: 0.02em;
} }
.metric-card--hero { .metric-card--hero {
@@ -1640,13 +1645,6 @@ html[data-theme='light'] .metric-card canvas {
gap: 6px; gap: 6px;
} }
.metric-type-btn {
font-size: 10px !important;
padding: 2px 8px !important;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.metric-card canvas.panning { .metric-card canvas.panning {
cursor: grabbing; cursor: grabbing;
} }
@@ -1719,67 +1717,6 @@ html[data-theme='light'] .metric-card canvas {
font-size: 10px; font-size: 10px;
} }
.metrics-detail {
margin-top: var(--space);
}
.charts-browser {
display: grid;
grid-template-columns: 280px 1fr;
gap: var(--space);
min-height: 420px;
}
.chart-catalog {
display: flex;
flex-direction: column;
gap: 10px;
max-height: calc(100vh - var(--titlebar-h) - 160px);
}
.catalog-list {
list-style: none;
margin: 0;
padding: 0;
overflow: auto;
flex: 1;
}
.catalog-list li {
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
}
.catalog-list li:hover {
background: var(--bg-hover);
}
.catalog-list li.active {
background: rgba(52, 211, 153, 0.12);
color: var(--text-primary);
}
.chart-detail header {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
}
.chart-detail h3 {
margin: 0;
font-size: 16px;
}
.dim-list {
margin: 12px 0 0;
font-family: var(--font-mono);
font-size: 11px;
white-space: pre-wrap;
}
/* ─── Lists / forms ─── */ /* ─── Lists / forms ─── */
.event-list, .event-list,
.peer-list { .peer-list {
@@ -1856,6 +1793,34 @@ html[data-theme='light'] .metric-card canvas {
font-weight: 650; font-weight: 650;
} }
.fleet-empty-actions {
margin: 18px 0 0;
}
.fleet-card-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.fleet-chip {
display: inline-flex;
align-items: baseline;
gap: 6px;
padding: 4px 8px;
border-radius: 8px;
background: var(--bg-elevated, rgba(255, 255, 255, 0.04));
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
font-size: 12px;
}
.fleet-chip strong {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.fleet-grid { .fleet-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
@@ -2179,6 +2144,12 @@ button.ghost.compact {
gap: 14px; gap: 14px;
} }
.settings-hint {
margin: 0;
font-size: 12px;
line-height: 1.4;
}
.settings-section h3 { .settings-section h3 {
margin: 8px 0 0; margin: 8px 0 0;
font-size: 13px; font-size: 13px;
@@ -2267,9 +2238,6 @@ code {
.dash-kpis { .dash-kpis {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.charts-browser {
grid-template-columns: 1fr;
}
#charts-view .metrics-shell { #charts-view .metrics-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
@@ -2284,9 +2252,8 @@ code {
margin-left: 0; margin-left: 0;
} }
#overview-view .chart-panel canvas { #overview-view .chart-panel canvas {
height: 76px !important; height: clamp(64px, 10vh, 96px) !important;
max-height: 76px; max-height: 96px;
flex-basis: 76px;
} }
} }