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'),
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
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 |
|---------|--------|
| 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 (1m6h) + 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
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,
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
View File
@@ -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" />
+7
View File
@@ -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)
})
+97 -36
View File
@@ -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,16 +488,53 @@ 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) {
const rr = Math.min(r, w / 2, h / 2)
if (h < 0.5) return
@@ -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
View File
@@ -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
View File
@@ -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 '—'
+91 -124
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));
}
/* 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,8 +537,15 @@ body[data-reduce-motion='1'] .view {
#overview-view:not(.hidden) {
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
@media (max-height: 640px) {
#overview-view:not(.hidden) {
overflow: auto;
}
}
@keyframes viewIn {
from {
@@ -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;
}
}