updates
This commit is contained in:
@@ -41,8 +41,6 @@ const els = {
|
||||
btnDisconnect: $('btn-disconnect'),
|
||||
btnInvite: $('btn-invite'),
|
||||
btnRefreshMeta: $('btn-refresh-meta'),
|
||||
peerList: $('peer-list'),
|
||||
bookmarkList: $('bookmark-list'),
|
||||
compareToggle: $('compare-toggle'),
|
||||
exploreChart: $('explore-chart'),
|
||||
chartCpuLabel: $('chart-cpu-label'),
|
||||
@@ -73,11 +71,6 @@ const els = {
|
||||
statNet: $('stat-net'),
|
||||
statHealth: $('stat-health'),
|
||||
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'),
|
||||
metricsWall: $('metrics-wall'),
|
||||
metricsPlay: $('metrics-play'),
|
||||
@@ -118,7 +111,6 @@ try {
|
||||
|
||||
/** @type {Record<string, object>} */
|
||||
let chartCatalog = {}
|
||||
let selectedCatalogChart = ''
|
||||
let exploreChartId = settings.defaultExplore || 'system.io'
|
||||
|
||||
/** @type {Map<string, { severity: string, score: number, threshold: number|null, until: number }>} */
|
||||
@@ -139,12 +131,14 @@ const series = {
|
||||
ioWrite: [],
|
||||
load: [],
|
||||
explore: [],
|
||||
detail: [],
|
||||
}
|
||||
|
||||
/** Per-peer CPU series for compare mode @type {Map<string, number[]>} */
|
||||
const peerCpu = new Map()
|
||||
|
||||
/** @type {object|null} last getFleetHealth payload for Fleet card chips */
|
||||
let lastFleetHealth = null
|
||||
|
||||
const metricsDashboard = createMetricsDashboard({
|
||||
els: {
|
||||
root: $('charts-view'),
|
||||
@@ -168,7 +162,7 @@ const metricsDashboard = createMetricsDashboard({
|
||||
getCatalog: () => chartCatalog,
|
||||
queryData: (args) => manager.request(Methods.queryData, args),
|
||||
getPoints: () => seriesMax(),
|
||||
onSelectChart: (id) => selectCatalogChart(id),
|
||||
isConnected: () => Boolean(manager.active?.connected),
|
||||
getPrefs: () => ({
|
||||
cardHeight: settings.metricsCardHeight,
|
||||
dimSort: settings.metricsDimSort,
|
||||
@@ -199,6 +193,15 @@ const metricsDashboard = createMetricsDashboard({
|
||||
})
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -473,9 +476,6 @@ function redrawAll() {
|
||||
mode: defaultModeFromMeta(chartCatalog[exploreChartId] || { chartType: 'line' }),
|
||||
}
|
||||
)
|
||||
if (selectedCatalogChart && series.detail.length) {
|
||||
paint('chart-detail', [{ values: series.detail, color: CHART_PALETTE.cpuUser }])
|
||||
}
|
||||
}
|
||||
|
||||
function updateActivePeerChip() {
|
||||
@@ -508,6 +508,7 @@ function loadFleetView() {
|
||||
activeId: manager.active?.publicKeyHex || null,
|
||||
getReconnectInfo: (id) => manager.getReconnectInfo(id),
|
||||
})
|
||||
enrichFleetMetrics(roster)
|
||||
const summary = summarizeFleet(roster)
|
||||
if (els.fleetStatLive) els.fleetStatLive.textContent = String(summary.live)
|
||||
if (els.fleetStatRetry) els.fleetStatRetry.textContent = String(summary.reconnecting)
|
||||
@@ -515,6 +516,7 @@ function loadFleetView() {
|
||||
if (els.fleetStatFailed) els.fleetStatFailed.textContent = String(summary.failed)
|
||||
|
||||
renderFleetCards(els.fleetCards, roster, {
|
||||
onConnect: () => showView('connect'),
|
||||
onActivate: async (peer) => {
|
||||
try {
|
||||
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) {
|
||||
if (!values) return 0
|
||||
if (chart === 'system.io' || chart.startsWith('disk_io.')) {
|
||||
@@ -702,10 +736,6 @@ function onSamples(samples, conn) {
|
||||
if (s.chart === exploreChartId) {
|
||||
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
|
||||
if (isActive) metricsDashboard.onSamples(samples || [])
|
||||
@@ -728,7 +758,6 @@ function prependAnomaly(ev) {
|
||||
li.addEventListener('click', () => {
|
||||
showView('charts')
|
||||
metricsDashboard.focusChartAt(ev.chart, ev.ts)
|
||||
selectCatalogChart(ev.chart).catch(() => {})
|
||||
if ([...els.exploreChart.options].some((o) => o.value === ev.chart)) {
|
||||
els.exploreChart.value = ev.chart
|
||||
exploreChartId = ev.chart
|
||||
@@ -847,64 +876,6 @@ function renderChartCatalog(_filter = '') {
|
||||
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() {
|
||||
const [info, auth, health, node, fleet] = await Promise.all([
|
||||
manager.request(Methods.getServerInfo, {}),
|
||||
@@ -913,6 +884,8 @@ async function refreshMeta() {
|
||||
manager.request(Methods.getNodeInfo, {}),
|
||||
manager.request(Methods.getFleetHealth, {}).catch(() => ({ enabled: false })),
|
||||
])
|
||||
lastFleetHealth = fleet || null
|
||||
if (els.fleetCards) loadFleetView()
|
||||
if (els.serverInfo) {
|
||||
els.serverInfo.textContent = JSON.stringify({ info, node, health, fleet }, null, 2)
|
||||
}
|
||||
|
||||
+19
-11
@@ -7,17 +7,17 @@ Related: [ROADMAP.md](./ROADMAP.md) Phase 6 · [DESKTOP.md](./DESKTOP.md) · [DA
|
||||
|
||||
---
|
||||
|
||||
## Current state (baseline)
|
||||
## Current state
|
||||
|
||||
| Surface | Today |
|
||||
|---------|--------|
|
||||
| Overview | Fixed 6-panel live grid + KPIs |
|
||||
| Charts | Flat searchable id list + single scalar detail canvas |
|
||||
| Time | Settings “points per chart” only |
|
||||
| Dimensions | Text dump; live path collapses to one number |
|
||||
| Alerts → chart | Jumps to Overview spotlight, not Charts wall |
|
||||
| Overview | Compact 6-panel live grid + KPIs; spark depth follows Charts timeframe when available |
|
||||
| Charts | Sectioned metrics wall (TOC + cards), shared time bar, multi-dim series |
|
||||
| Time | Charts presets (1m–6h) + play/pause; retention is advisory (`thin-history`) |
|
||||
| Dimensions | Legend chips show/hide; chart type cycle (line/area/stacked/bar/pie) |
|
||||
| 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
|
||||
|
||||
### P0 — Foundation (in progress)
|
||||
### P0 — Foundation
|
||||
|
||||
- [x] Roadmap + this doc
|
||||
- [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] Wallboard / force-play mode
|
||||
- [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] 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.
|
||||
2. TOC jumps scroll the wall; search filters TOC + wall.
|
||||
3. Play/pause + time preset change refetch visible cards.
|
||||
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
@@ -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,
|
||||
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 |
|
||||
| Drag-reorder pins + keyboard shortcuts | Done |
|
||||
| Taxonomy coverage test + EXTENDING note | Done |
|
||||
| Desktop polish (layout fill, plot domain, legacy detail removed, Fleet chips) | Done |
|
||||
| Multi-named custom boards | Later |
|
||||
|
||||
**Phase 6 exit (P0)** — Charts tab lists every agent chart in sections; shared
|
||||
|
||||
+2
-13
@@ -221,18 +221,9 @@
|
||||
<aside class="metrics-toc">
|
||||
<input id="chart-search" type="search" placeholder="Filter charts…" autocomplete="off" />
|
||||
<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>
|
||||
<div id="metrics-wall" class="metrics-wall" tabindex="0"></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>
|
||||
|
||||
<!-- Alerts -->
|
||||
@@ -278,9 +269,6 @@
|
||||
</section>
|
||||
<div id="fleet-cards" class="fleet-cards"></div>
|
||||
<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>
|
||||
|
||||
<!-- Connect -->
|
||||
@@ -361,9 +349,10 @@
|
||||
<div class="dash-card settings-section">
|
||||
<h3>History window</h3>
|
||||
<label>
|
||||
Points per chart
|
||||
Overview spark depth
|
||||
<input id="setting-chart-points" type="number" min="30" max="300" step="10" value="90" />
|
||||
</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>
|
||||
Default spotlight chart
|
||||
<input id="setting-default-explore" type="text" value="system.io" />
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
normalizeChartMode,
|
||||
isCompositionChart,
|
||||
} from '../shared/chart-types.js'
|
||||
import { padLeftFor } from '../ui/charts.js'
|
||||
|
||||
test('normalizeChartMode aliases', (t) => {
|
||||
t.is(normalizeChartMode('STACK'), 'stacked')
|
||||
@@ -45,3 +46,9 @@ test('line charts cycle through sensible modes', (t) => {
|
||||
t.ok(seen.has('bar'))
|
||||
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
@@ -19,6 +19,8 @@ import { normalizeChartMode } from '../shared/chart-types.js'
|
||||
* units?: string,
|
||||
* emptyMessage?: string,
|
||||
* dimmed?: boolean,
|
||||
* windowSeconds?: number|null,
|
||||
* endOffset?: number,
|
||||
* }} [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 {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 = {}) {
|
||||
if (!canvas) return
|
||||
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 maxPoints = opts.maxPoints || 90
|
||||
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 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 plotH = Math.max(1, h - padT - padB)
|
||||
|
||||
const prepared = prepareSeries(lines, maxPoints)
|
||||
if (!prepared.length) {
|
||||
drawEmpty(ctx, padL, padT, opts.emptyMessage)
|
||||
drawEmpty(ctx, w, h, opts.emptyMessage)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -72,18 +87,21 @@ export function drawMultiChart(canvas, lines, opts = {}) {
|
||||
|
||||
const stacked = mode === 'stacked' || opts.stacked === true
|
||||
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
|
||||
|
||||
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)) {
|
||||
drawThreshold(ctx, padL, plotW, yAt(opts.threshold), opts.severity)
|
||||
}
|
||||
|
||||
if (stacked) {
|
||||
drawStacked(ctx, prepared, xAt, yAt, maxPoints)
|
||||
drawStacked(ctx, prepared, xAt, yAt)
|
||||
} else {
|
||||
prepared.forEach((line) => {
|
||||
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)) {
|
||||
drawHoverCrosshair(ctx, prepared, opts.hoverIndex, xAt, yAt, padT, plotH, maxPoints)
|
||||
drawHoverCrosshair(ctx, prepared, opts.hoverIndex, xAt, yAt, padT, plotH)
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
@@ -117,10 +135,11 @@ export function drawBarChart(canvas, lines, opts = {}) {
|
||||
|
||||
const maxPoints = opts.maxPoints || 90
|
||||
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 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 plotH = Math.max(1, h - padT - padB)
|
||||
const grouped = Boolean(opts.grouped)
|
||||
@@ -133,7 +152,7 @@ export function drawBarChart(canvas, lines, opts = {}) {
|
||||
.slice(0, 6)
|
||||
}
|
||||
if (!prepared.length) {
|
||||
drawEmpty(ctx, padL, padT, opts.emptyMessage)
|
||||
drawEmpty(ctx, w, h, opts.emptyMessage)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -150,6 +169,7 @@ export function drawBarChart(canvas, lines, opts = {}) {
|
||||
|
||||
drawGrid(ctx, padL, padT, plotW, plotH, min, max, yAt)
|
||||
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 groupCount = grouped ? prepared.length : 1
|
||||
@@ -228,12 +248,17 @@ export function drawPieChart(canvas, lines, opts = {}) {
|
||||
.sort((a, b) => b.value - a.value)
|
||||
|
||||
if (!slices.length) {
|
||||
drawEmpty(ctx, 12, 16, opts.emptyMessage || 'No data')
|
||||
drawEmpty(ctx, w, h, opts.emptyMessage || 'No data')
|
||||
return
|
||||
}
|
||||
|
||||
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 cx = w * 0.38
|
||||
const cy = h / 2
|
||||
@@ -253,13 +278,13 @@ export function drawPieChart(canvas, lines, opts = {}) {
|
||||
}
|
||||
|
||||
// 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.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(formatAxis(total), cx, cy - 6)
|
||||
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.fillText(String(opts.units).slice(0, 12), cx, cy + 10)
|
||||
}
|
||||
@@ -275,7 +300,7 @@ export function drawPieChart(canvas, lines, opts = {}) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(lx, ly, 3.5, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.fillStyle = 'rgba(200, 209, 223, 0.92)'
|
||||
ctx.fillStyle = legendInk
|
||||
ctx.font = '11px "IBM Plex Mono", ui-monospace, Menlo, monospace'
|
||||
const name = slice.label.length > 14 ? slice.label.slice(0, 13) + '…' : slice.label
|
||||
ctx.fillText(`${name} ${pct}%`, lx + 10, ly)
|
||||
@@ -370,7 +395,7 @@ function drawThreshold(ctx, padL, plotW, y, severity) {
|
||||
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))
|
||||
/** @type {number[]} */
|
||||
const acc = new Array(len).fill(0)
|
||||
@@ -378,13 +403,13 @@ function drawStacked(ctx, prepared, xAt, yAt, maxPoints) {
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i < len; 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)
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
}
|
||||
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.fillStyle = withAlpha(line.color, 0.5)
|
||||
@@ -392,7 +417,7 @@ function drawStacked(ctx, prepared, xAt, yAt, maxPoints) {
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i < len; 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)
|
||||
if (i === 0) ctx.moveTo(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))
|
||||
ctx.beginPath()
|
||||
values.forEach((v, i) => {
|
||||
const x = xAt(i, len)
|
||||
const x = xAt(i)
|
||||
const y = yAt(v)
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
})
|
||||
ctx.lineTo(xAt(len - 1, len), padT + plotH)
|
||||
ctx.lineTo(xAt(0, len), padT + plotH)
|
||||
ctx.lineTo(xAt(len - 1), padT + plotH)
|
||||
ctx.lineTo(xAt(0), padT + plotH)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = grad
|
||||
ctx.fill()
|
||||
@@ -430,7 +455,7 @@ function drawLineSeries(ctx, line, xAt, yAt, padT, plotH, wantFill) {
|
||||
ctx.lineCap = 'round'
|
||||
ctx.beginPath()
|
||||
values.forEach((v, i) => {
|
||||
const x = xAt(i, len)
|
||||
const x = xAt(i)
|
||||
const y = yAt(v)
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
@@ -438,10 +463,10 @@ function drawLineSeries(ctx, line, xAt, yAt, padT, plotH, wantFill) {
|
||||
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 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.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
@@ -453,7 +478,7 @@ function drawHoverCrosshair(ctx, prepared, hoverIndex, xAt, yAt, padT, plotH, ma
|
||||
if (v == null) continue
|
||||
const y = yAt(v)
|
||||
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.fill()
|
||||
ctx.fillStyle = line.color
|
||||
@@ -463,14 +488,51 @@ function drawHoverCrosshair(ctx, prepared, hoverIndex, xAt, yAt, padT, plotH, ma
|
||||
}
|
||||
}
|
||||
|
||||
function drawEmpty(ctx, padL, padT, message) {
|
||||
ctx.fillStyle = 'rgba(154, 168, 188, 0.5)'
|
||||
function drawXAxis(ctx, padL, padT, plotW, plotH, opts) {
|
||||
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'
|
||||
String(message || 'No data')
|
||||
.split('\n')
|
||||
.forEach((line, i) => {
|
||||
ctx.fillText(line, padL + 8, padT + 18 + i * 16)
|
||||
})
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
const lines = String(message || 'No data').split('\n')
|
||||
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) {
|
||||
@@ -497,19 +559,18 @@ function clampIdx(idx, len) {
|
||||
* Map pointer x → series index for a canvas using the same layout as drawMultiChart.
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @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 = {}) {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
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 plotW = Math.max(1, w - padL - padR)
|
||||
const x = clientX - rect.left - padL
|
||||
if (x < 0 || x > plotW) return null
|
||||
const maxPoints = opts.maxPoints || 90
|
||||
const len = Math.max(2, opts.seriesLen || maxPoints)
|
||||
const idx = Math.round((x / plotW) * (Math.max(maxPoints, len) - 1))
|
||||
const len = Math.max(2, opts.seriesLen || 2)
|
||||
const idx = Math.round((x / plotW) * (len - 1))
|
||||
return Math.max(0, Math.min(len - 1, idx))
|
||||
}
|
||||
|
||||
|
||||
+58
-26
@@ -10,7 +10,7 @@ import {
|
||||
nextChartMode,
|
||||
normalizeChartMode,
|
||||
} 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']
|
||||
|
||||
@@ -64,7 +64,7 @@ function smoothScrollIntoView(el, block = 'start') {
|
||||
* getCatalog: () => Record<string, object>,
|
||||
* queryData: (args: object) => Promise<object>,
|
||||
* getPoints: () => number,
|
||||
* onSelectChart?: (id: string) => void,
|
||||
* isConnected?: () => boolean,
|
||||
* getPrefs?: () => {
|
||||
* cardHeight?: number,
|
||||
* dimSort?: 'name'|'value',
|
||||
@@ -116,6 +116,24 @@ export function createMetricsDashboard(opts) {
|
||||
fetchGen: new Map(),
|
||||
/** Seconds of history available across catalog (0 = unknown). */
|
||||
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() {
|
||||
@@ -250,11 +268,8 @@ export function createMetricsDashboard(opts) {
|
||||
}
|
||||
|
||||
function presetAvailable(seconds) {
|
||||
const catalog = opts.getCatalog() || {}
|
||||
// Only hard-disable when nothing is connected / no catalog yet
|
||||
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.
|
||||
// Always selectable — empty wall / disconnected shows idle state.
|
||||
// Retention is advisory in the hint, not a hard lock.
|
||||
void seconds
|
||||
return true
|
||||
}
|
||||
@@ -281,25 +296,25 @@ export function createMetricsDashboard(opts) {
|
||||
if (!opts.els.presets) return
|
||||
const retention = computeRetentionSeconds()
|
||||
const catalogEmpty = !Object.keys(opts.getCatalog() || {}).length
|
||||
const connected = Boolean(opts.isConnected?.() ?? !catalogEmpty)
|
||||
opts.els.presets.querySelectorAll('[data-preset]').forEach((btn) => {
|
||||
const id = btn.getAttribute('data-preset') || ''
|
||||
const p = TIME_PRESETS.find((x) => x.id === id)
|
||||
const seconds = p?.seconds || 0
|
||||
const ok = !catalogEmpty && presetAvailable(seconds)
|
||||
const thin = !catalogEmpty && retention > 0 && retention + 30 < seconds
|
||||
const thin = connected && !catalogEmpty && retention > 0 && retention + 30 < seconds
|
||||
btn.classList.toggle('active', id === state.presetId)
|
||||
btn.classList.toggle('thin-history', thin)
|
||||
btn.disabled = !ok
|
||||
btn.title = !ok
|
||||
? 'Connect an agent to load history'
|
||||
btn.disabled = false
|
||||
btn.title = !connected
|
||||
? `Last ${id} · connect an agent to load samples`
|
||||
: thin
|
||||
? `Last ${id} · ~${formatDuration(retention)} buffered (may be sparse)`
|
||||
: `Last ${id}`
|
||||
})
|
||||
const hint = opts.els.retentionHint
|
||||
if (hint) {
|
||||
if (catalogEmpty) {
|
||||
hint.textContent = 'Connect an agent to enable time windows.'
|
||||
if (!connected || catalogEmpty) {
|
||||
hint.textContent = 'Connect an agent to populate charts for the selected window.'
|
||||
hint.classList.remove('hidden')
|
||||
} else if (retention > 0 && retention < state.afterSeconds) {
|
||||
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'
|
||||
: 'No data'
|
||||
const units = card.meta?.units || ''
|
||||
const showY = mode !== 'pie'
|
||||
drawChart(canvas, lines, {
|
||||
mode,
|
||||
maxPoints: maxPoints(),
|
||||
showYAxis: mode !== 'pie',
|
||||
showYAxis: showY,
|
||||
padLeft: padLeftFor(units, showY),
|
||||
hoverIndex: mode === 'pie' ? null : state.hoverIndex,
|
||||
threshold: mode === 'pie' ? null : anomaly?.threshold ?? null,
|
||||
severity: anomaly?.severity || null,
|
||||
emptyMessage: emptyMsg,
|
||||
dimmed: Boolean(card.updating),
|
||||
units,
|
||||
windowSeconds: mode === 'pie' ? null : state.afterSeconds,
|
||||
endOffset: state.endOffset,
|
||||
})
|
||||
updateCardTooltip(article, id, card, lines, units)
|
||||
const statsEl = article.querySelector('.metric-card-stats')
|
||||
@@ -551,7 +570,6 @@ export function createMetricsDashboard(opts) {
|
||||
statsEl.innerHTML = renderStatsHtml(card, dims, hidden, units)
|
||||
}
|
||||
if (legend && !card.updating) {
|
||||
const visibleLines = lines.filter((l) => !l.hidden)
|
||||
const show = lines.slice(0, LEGEND_CHIP_CAP)
|
||||
const extra = Math.max(0, lines.length - LEGEND_CHIP_CAP)
|
||||
const unitSuffix = units ? ` ${units}` : ''
|
||||
@@ -566,7 +584,6 @@ export function createMetricsDashboard(opts) {
|
||||
(extra
|
||||
? `<span class="dim-chip-more muted" title="${extra} more dimensions">+${extra}</span>`
|
||||
: '')
|
||||
void visibleLines
|
||||
if (legend.dataset.sig !== nextHtml) {
|
||||
legend.dataset.sig = nextHtml
|
||||
legend.innerHTML = nextHtml
|
||||
@@ -782,7 +799,8 @@ export function createMetricsDashboard(opts) {
|
||||
: ''
|
||||
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)
|
||||
opts.els.hoverReadout.textContent = `~${formatDuration(Math.max(0, age))} ago`
|
||||
}
|
||||
@@ -792,20 +810,27 @@ export function createMetricsDashboard(opts) {
|
||||
|
||||
canvas.addEventListener('mousemove', (ev) => {
|
||||
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, {
|
||||
maxPoints: maxPoints(),
|
||||
showYAxis: true,
|
||||
seriesLen: maxPoints(),
|
||||
showYAxis: showY,
|
||||
seriesLen,
|
||||
units,
|
||||
padLeft: padLeftFor(units, showY),
|
||||
})
|
||||
state.hoverIndex = idx
|
||||
state.hoverSeriesLen = seriesLen
|
||||
updateHoverReadout()
|
||||
for (const id of state.visible) paintCard(id)
|
||||
scheduleHoverPaint()
|
||||
})
|
||||
canvas.addEventListener('mouseleave', () => {
|
||||
if (state.pan?.active) return
|
||||
state.hoverIndex = null
|
||||
state.hoverSeriesLen = 0
|
||||
updateHoverReadout()
|
||||
for (const id of state.visible) paintCard(id)
|
||||
scheduleHoverPaint()
|
||||
})
|
||||
|
||||
// Natural wall scroll by default. Zoom only with an intentional modifier
|
||||
@@ -1207,7 +1232,14 @@ export function createMetricsDashboard(opts) {
|
||||
})
|
||||
article.addEventListener('dblclick', (ev) => {
|
||||
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.observer?.observe(article)
|
||||
@@ -1221,9 +1253,9 @@ export function createMetricsDashboard(opts) {
|
||||
let startH = 0
|
||||
const onMove = (ev) => {
|
||||
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')
|
||||
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)
|
||||
}
|
||||
|
||||
+36
@@ -17,6 +17,9 @@
|
||||
* capability: string|null,
|
||||
* adminSeed: string|null,
|
||||
* lastConnectedAt: number|null,
|
||||
* cpu?: number|null,
|
||||
* ram?: number|null,
|
||||
* health?: string|null,
|
||||
* }} FleetPeer
|
||||
*/
|
||||
|
||||
@@ -131,6 +134,7 @@ export function summarizeFleet(roster) {
|
||||
* onForget: (peer: FleetPeer) => void|Promise<void>,
|
||||
* onOpenCharts: (peer: FleetPeer) => void|Promise<void>,
|
||||
* onAlias: (peer: FleetPeer) => void|Promise<void>,
|
||||
* onConnect?: () => void,
|
||||
* }} handlers
|
||||
*/
|
||||
export function renderFleetCards(root, roster, handlers) {
|
||||
@@ -143,7 +147,17 @@ export function renderFleetCards(root, roster, handlers) {
|
||||
empty.innerHTML = `
|
||||
<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="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)
|
||||
return
|
||||
}
|
||||
@@ -178,6 +192,8 @@ function buildCard(peer, handlers) {
|
||||
const badge = badgeFor(state, peer)
|
||||
const health = healthLine(peer, state)
|
||||
|
||||
const chips = metricChips(peer)
|
||||
|
||||
card.innerHTML = `
|
||||
<header class="fleet-card-head">
|
||||
<div class="fleet-card-titles">
|
||||
@@ -186,6 +202,7 @@ function buildCard(peer, handlers) {
|
||||
</div>
|
||||
<span class="fleet-badge fleet-badge--${state}">${escapeHtml(badge)}</span>
|
||||
</header>
|
||||
${chips}
|
||||
<ul class="fleet-card-meta">
|
||||
<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>
|
||||
@@ -218,12 +235,31 @@ function badgeFor(state, peer) {
|
||||
}
|
||||
|
||||
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 === 'reconnecting') return 'Reconnecting…'
|
||||
if (state === 'failed') return 'Max reconnects reached'
|
||||
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) {
|
||||
if (peer.connected) return 'now'
|
||||
if (!peer.lastConnectedAt) return '—'
|
||||
|
||||
+92
-125
@@ -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));
|
||||
}
|
||||
|
||||
/* Slim scrollbars on the element that actually scrolls (views / walls — not nested) */
|
||||
/* Slim scrollbars — shared across walls, TOC, sidebar, fleet, logs */
|
||||
.view,
|
||||
.metrics-wall,
|
||||
.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-nav,
|
||||
.metrics-toc,
|
||||
.metric-card-stats,
|
||||
.related-panel,
|
||||
#sidebar,
|
||||
.fleet-cards,
|
||||
.event-list,
|
||||
@@ -204,39 +174,56 @@ html[data-theme='light'] {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) transparent;
|
||||
}
|
||||
|
||||
.view::-webkit-scrollbar,
|
||||
.metrics-wall::-webkit-scrollbar,
|
||||
.metrics-toc-nav::-webkit-scrollbar,
|
||||
.metrics-toc::-webkit-scrollbar,
|
||||
.metric-card-stats::-webkit-scrollbar,
|
||||
.related-panel::-webkit-scrollbar,
|
||||
#sidebar::-webkit-scrollbar,
|
||||
.fleet-cards::-webkit-scrollbar,
|
||||
.event-list::-webkit-scrollbar,
|
||||
.log::-webkit-scrollbar {
|
||||
width: var(--scrollbar-size);
|
||||
height: var(--scrollbar-size);
|
||||
.log::-webkit-scrollbar,
|
||||
.invite-out::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.view::-webkit-scrollbar-track,
|
||||
.metrics-wall::-webkit-scrollbar-track,
|
||||
.metrics-toc-nav::-webkit-scrollbar-track,
|
||||
.metrics-toc::-webkit-scrollbar-track,
|
||||
.metric-card-stats::-webkit-scrollbar-track,
|
||||
.related-panel::-webkit-scrollbar-track,
|
||||
#sidebar::-webkit-scrollbar-track,
|
||||
.fleet-cards::-webkit-scrollbar-track,
|
||||
.event-list::-webkit-scrollbar-track,
|
||||
.log::-webkit-scrollbar-track {
|
||||
.log::-webkit-scrollbar-track,
|
||||
.invite-out::-webkit-scrollbar-track {
|
||||
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,
|
||||
.metric-card-stats::-webkit-scrollbar-thumb,
|
||||
.related-panel::-webkit-scrollbar-thumb,
|
||||
#sidebar::-webkit-scrollbar-thumb,
|
||||
.fleet-cards::-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);
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
background-clip: padding-box;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
#sidebar::-webkit-scrollbar-thumb:hover {
|
||||
.view::-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-clip: padding-box;
|
||||
}
|
||||
@@ -550,7 +537,14 @@ body[data-reduce-motion='1'] .view {
|
||||
#overview-view:not(.hidden) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-height: 640px) {
|
||||
#overview-view:not(.hidden) {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes viewIn {
|
||||
@@ -802,11 +796,11 @@ body.is-offline .offline-banner:not(.hidden) {
|
||||
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 {
|
||||
height: 84px !important;
|
||||
max-height: 84px;
|
||||
flex: 0 0 84px;
|
||||
height: clamp(72px, 11vh, 118px) !important;
|
||||
max-height: 118px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
html[data-theme='light'] .chart-panel canvas {
|
||||
@@ -820,7 +814,7 @@ html[data-theme='light'] .chart-panel canvas {
|
||||
margin-top: 4px;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
max-height: 22px;
|
||||
max-height: 28px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1146,6 +1140,15 @@ button.metrics-tf.thin-history:not(.active) {
|
||||
.related-panel {
|
||||
margin-bottom: var(--space);
|
||||
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 {
|
||||
@@ -1414,10 +1417,12 @@ button.metrics-tf.thin-history:not(.active) {
|
||||
}
|
||||
|
||||
.metric-type-btn {
|
||||
text-transform: lowercase;
|
||||
min-width: 3.4rem !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 {
|
||||
@@ -1640,13 +1645,6 @@ html[data-theme='light'] .metric-card canvas {
|
||||
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;
|
||||
}
|
||||
@@ -1719,67 +1717,6 @@ html[data-theme='light'] .metric-card canvas {
|
||||
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 ─── */
|
||||
.event-list,
|
||||
.peer-list {
|
||||
@@ -1856,6 +1793,34 @@ html[data-theme='light'] .metric-card canvas {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
@@ -2179,6 +2144,12 @@ button.ghost.compact {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
@@ -2267,9 +2238,6 @@ code {
|
||||
.dash-kpis {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.charts-browser {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
#charts-view .metrics-shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
@@ -2284,9 +2252,8 @@ code {
|
||||
margin-left: 0;
|
||||
}
|
||||
#overview-view .chart-panel canvas {
|
||||
height: 76px !important;
|
||||
max-height: 76px;
|
||||
flex-basis: 76px;
|
||||
height: clamp(64px, 10vh, 96px) !important;
|
||||
max-height: 96px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user