Update
CI / test (push) Successful in 57s
Release rolling / release (push) Successful in 7m0s

This commit is contained in:
Raven Scott
2026-07-18 21:02:15 -04:00
parent 66586d2181
commit 700e621882
7 changed files with 1063 additions and 191 deletions
+34 -19
View File
@@ -22,7 +22,8 @@ import {
saveSettings,
applySettingsToDom,
} from './client/settings.js'
import { drawMultiChart, CHART_PALETTE, pushRing } from './ui/charts.js'
import { drawChart, CHART_PALETTE, pushRing } from './ui/charts.js'
import { defaultModeFromMeta } from './shared/chart-types.js'
import { createMetricsDashboard } from './ui/dashboard.js'
import {
buildFleetRoster,
@@ -266,7 +267,7 @@ function pushPeerCpu(peerId, value) {
}
function paint(canvasId, lines, opts = {}) {
drawMultiChart($(canvasId), lines, { ...opts, maxPoints: seriesMax() })
drawChart($(canvasId), lines, { ...opts, maxPoints: seriesMax() })
}
function panelForChart(chart) {
@@ -388,9 +389,12 @@ function redrawCpu() {
{ values: series.cpuIowait, color: CHART_PALETTE.cpuIowait, label: 'iowait', fill: false },
].filter((l) => l.values.length >= 2)
if (!lines.length) {
paint('chart-cpu', [{ values: series.cpu, color: CHART_PALETTE.cpuUser }], opts)
paint('chart-cpu', [{ values: series.cpu, color: CHART_PALETTE.cpuUser }], {
...opts,
mode: 'area',
})
} else {
paint('chart-cpu', lines, { ...opts, stacked: false })
paint('chart-cpu', lines, { ...opts, mode: 'stacked' })
}
if (els.chartCpuLabel) els.chartCpuLabel.textContent = 'system.cpu'
if (els.legendCpu) {
@@ -405,38 +409,49 @@ function redrawAll() {
applyAnomalyHighlights()
redrawCpu()
const ramChart = anomalyByChart.has('mem.available') ? 'mem.available' : 'system.ram'
paint('chart-ram', [{ values: series.ram, color: CHART_PALETTE.ram }], anomalyOptsFor(ramChart))
paint(
'chart-ram',
[{ values: series.ram, color: CHART_PALETTE.ram }],
{ ...anomalyOptsFor(ramChart), mode: 'area' }
)
paint(
'chart-net',
[
{ values: series.net, color: CHART_PALETTE.net, label: 'rx', fill: false },
{ values: series.netTx, color: CHART_PALETTE.disk, label: 'tx', fill: false },
{ values: series.net, color: CHART_PALETTE.net, label: 'rx' },
{ values: series.netTx, color: CHART_PALETTE.disk, label: 'tx' },
].filter((l) => l.values.length >= 2).length
? [
{ values: series.net, color: CHART_PALETTE.net, fill: false },
{ values: series.netTx, color: CHART_PALETTE.disk, fill: false },
{ values: series.net, color: CHART_PALETTE.net },
{ values: series.netTx, color: CHART_PALETTE.disk },
]
: [{ values: series.net, color: CHART_PALETTE.net }],
anomalyOptsFor('system.net')
{ ...anomalyOptsFor('system.net'), mode: 'area' }
)
paint(
'chart-io',
[
{ values: series.io, color: CHART_PALETTE.disk, fill: false },
{ values: series.ioWrite, color: CHART_PALETTE.cpuIowait, fill: false },
{ values: series.io, color: CHART_PALETTE.disk },
{ values: series.ioWrite, color: CHART_PALETTE.cpuIowait },
].filter((l) => l.values.length >= 2).length
? [
{ values: series.io, color: CHART_PALETTE.disk, fill: false },
{ values: series.ioWrite, color: CHART_PALETTE.cpuIowait, fill: false },
{ values: series.io, color: CHART_PALETTE.disk },
{ values: series.ioWrite, color: CHART_PALETTE.cpuIowait },
]
: [{ values: series.io, color: CHART_PALETTE.disk }],
anomalyOptsFor('system.io')
{ ...anomalyOptsFor('system.io'), mode: 'area' }
)
paint(
'chart-load',
[{ values: series.load, color: CHART_PALETTE.load }],
{ ...anomalyOptsFor('system.load'), mode: 'line' }
)
paint('chart-load', [{ values: series.load, color: CHART_PALETTE.load }], anomalyOptsFor('system.load'))
paint(
'chart-explore',
[{ values: series.explore, color: CHART_PALETTE.explore }],
anomalyOptsFor(exploreChartId)
{
...anomalyOptsFor(exploreChartId),
mode: defaultModeFromMeta(chartCatalog[exploreChartId] || { chartType: 'line' }),
}
)
if (selectedCatalogChart && series.detail.length) {
paint('chart-detail', [{ values: series.detail, color: CHART_PALETTE.cpuUser }])
@@ -854,8 +869,8 @@ async function selectCatalogChart(id) {
if (i === 0) series.detail = values.slice()
}
paint('chart-detail', lines, {
stacked: String(meta.chartType || '').toLowerCase() === 'stacked',
showYAxis: true,
mode: defaultModeFromMeta(meta),
showYAxis: defaultModeFromMeta(meta) !== 'pie',
})
} else {
for (const row of q.data || []) pushPoint('detail', row[1] ?? 0)
+1 -1
View File
@@ -54,7 +54,7 @@ Do not name third-party products in code, commits, or user-facing copy.
- [x] Pan / zoom / reset on a card (updates shared window)
- [x] Synced crosshair / shared hover time across visible cards
- [x] Dimension show/hide + sort by name / latest value
- [x] Chart type switch (line / area / stacked) where units allow
- [x] Chart type switch (line / area / stacked / bar / multibar / pie) — catalog default + sensible cycle
- [x] Resize card height; persist prefs
- [x] Section overview KPI strip (latest values) above detail charts
+134
View File
@@ -0,0 +1,134 @@
/**
* Chart display modes — how a metrics card should paint its series.
*
* Catalog agents emit chartType: line | area | stacked.
* The desktop can also render bar / multibar / pie where they make sense.
*/
/** @typedef {'line'|'area'|'stacked'|'bar'|'multibar'|'pie'} ChartMode */
/** @type {ChartMode[]} */
export const CHART_MODES = ['line', 'area', 'stacked', 'bar', 'multibar', 'pie']
/** Short labels for the type toggle button */
export const CHART_MODE_LABEL = {
line: 'line',
area: 'area',
stacked: 'stack',
bar: 'bar',
multibar: 'bars',
pie: 'pie',
}
/**
* @param {unknown} raw
* @returns {ChartMode}
*/
export function normalizeChartMode(raw) {
const m = String(raw || 'line')
.trim()
.toLowerCase()
.replace(/[_-]/g, '')
if (m === 'multibar' || m === 'bargroup') return 'multibar'
if (m === 'stack') return 'stacked'
if (CHART_MODES.includes(/** @type {ChartMode} */ (m))) {
return /** @type {ChartMode} */ (m)
}
return 'line'
}
/**
* Default mode from catalog metadata.
* @param {object} [meta]
* @returns {ChartMode}
*/
export function defaultModeFromMeta(meta = {}) {
return normalizeChartMode(meta.chartType || meta.chart_type || 'line')
}
/**
* @param {object} [meta]
*/
function dimCount(meta = {}) {
const dims = meta.dimensions
if (Array.isArray(dims)) return dims.length
if (dims && typeof dims === 'object') return Object.keys(dims).length
return 0
}
/**
* Composition charts (parts of a whole) — pie / stacked are natural.
* @param {object} [meta]
*/
export function isCompositionChart(meta = {}) {
const mode = defaultModeFromMeta(meta)
if (mode === 'stacked') return true
const units = String(meta.units || '')
if (/%|percentage|percent/i.test(units)) return true
const id = String(meta.id || meta.name || '')
// Common composition contexts
if (/\.(cpu|ram|swap|slab|kernel|states|usage)$/i.test(id)) return true
if (/^(system\.cpu|system\.ram|mem\.(swap|kernel|slab))/i.test(id)) return true
return false
}
/**
* Modes that make sense for this chart (catalog default first).
* @param {object} [meta]
* @returns {ChartMode[]}
*/
export function allowedModesFor(meta = {}) {
const preferred = defaultModeFromMeta(meta)
const n = dimCount(meta)
const composition = isCompositionChart(meta)
/** @type {Set<ChartMode>} */
const set = new Set(['line', 'area'])
if (n !== 1) set.add('stacked')
set.add('bar')
if (n === 0 || (n >= 2 && n <= 16)) set.add('multibar')
if (composition || preferred === 'stacked') set.add('pie')
// Always allow the catalog default
set.add(preferred)
const order = /** @type {ChartMode[]} */ ([
preferred,
...CHART_MODES.filter((m) => m !== preferred && set.has(m)),
])
return order
}
/**
* Cycle to the next allowed mode.
* @param {ChartMode|string} current
* @param {object} [meta]
* @returns {ChartMode}
*/
export function nextChartMode(current, meta = {}) {
const allowed = allowedModesFor(meta)
const cur = normalizeChartMode(current)
const idx = allowed.indexOf(cur)
return allowed[(idx < 0 ? 0 : idx + 1) % allowed.length]
}
/**
* Human hint for tooltips / empty states.
* @param {ChartMode|string} mode
*/
export function chartModeHint(mode) {
switch (normalizeChartMode(mode)) {
case 'area':
return 'Filled time series — good for volume and rates'
case 'stacked':
return 'Stacked over time — parts of a whole'
case 'bar':
return 'Columns over time — compare magnitude per sample'
case 'multibar':
return 'Grouped bars — compare dimensions side by side'
case 'pie':
return 'Snapshot share — latest values as a whole'
default:
return 'Lines over time — independent series'
}
}
+47
View File
@@ -0,0 +1,47 @@
import test from 'brittle'
import {
allowedModesFor,
defaultModeFromMeta,
nextChartMode,
normalizeChartMode,
isCompositionChart,
} from '../shared/chart-types.js'
test('normalizeChartMode aliases', (t) => {
t.is(normalizeChartMode('STACK'), 'stacked')
t.is(normalizeChartMode('multi-bar'), 'multibar')
t.is(normalizeChartMode('nope'), 'line')
})
test('defaultModeFromMeta respects catalog', (t) => {
t.is(defaultModeFromMeta({ chartType: 'area' }), 'area')
t.is(defaultModeFromMeta({ chart_type: 'stacked' }), 'stacked')
t.is(defaultModeFromMeta({}), 'line')
})
test('composition charts get pie in allowed modes', (t) => {
const meta = {
id: 'system.cpu',
chartType: 'stacked',
units: 'percentage',
dimensions: ['user', 'system', 'idle'],
}
t.ok(isCompositionChart(meta))
const allowed = allowedModesFor(meta)
t.ok(allowed.includes('pie'))
t.ok(allowed.includes('stacked'))
t.is(allowed[0], 'stacked')
})
test('line charts cycle through sensible modes', (t) => {
const meta = { chartType: 'line', dimensions: ['a', 'b'] }
let mode = 'line'
const seen = new Set()
for (let i = 0; i < 8; i++) {
mode = nextChartMode(mode, meta)
seen.add(mode)
}
t.ok(seen.has('area'))
t.ok(seen.has('bar'))
t.absent(seen.has('pie'))
})
+435 -128
View File
@@ -1,11 +1,13 @@
/**
* Canvas chart helpers — multi-series lines / stacked areas for the desktop.
* Canvas chart helpers — line / area / stacked / bar / multibar / pie.
*/
import { normalizeChartMode } from '../shared/chart-types.js'
/**
* @param {HTMLCanvasElement|null} canvas
* @param {Array<{ values: number[], color: string, label?: string, fill?: boolean, hidden?: boolean }>} lines
* @param {{
* mode?: string,
* threshold?: number|null,
* severity?: string|null,
* stacked?: boolean,
@@ -19,6 +21,25 @@
* dimmed?: boolean,
* }} [opts]
*/
export function drawChart(canvas, lines, opts = {}) {
const mode = normalizeChartMode(
opts.mode || (opts.stacked ? 'stacked' : undefined) || 'line'
)
if (mode === 'pie') return drawPieChart(canvas, lines, opts)
if (mode === 'bar') return drawBarChart(canvas, lines, { ...opts, grouped: false })
if (mode === 'multibar') return drawBarChart(canvas, lines, { ...opts, grouped: true })
return drawMultiChart(canvas, lines, {
...opts,
stacked: mode === 'stacked',
mode,
})
}
/**
* @param {HTMLCanvasElement|null} canvas
* @param {Array<{ values: number[], color: string, label?: string, fill?: boolean, hidden?: boolean }>} lines
* @param {object} [opts]
*/
export function drawMultiChart(canvas, lines, opts = {}) {
if (!canvas) return
const ctx = canvas.getContext('2d')
@@ -31,161 +52,445 @@ export function drawMultiChart(canvas, lines, opts = {}) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)
const mode = normalizeChartMode(opts.mode || (opts.stacked ? 'stacked' : 'line'))
const maxPoints = opts.maxPoints || 90
const padL = opts.padLeft ?? (opts.showYAxis ? 44 : 0)
const padR = 8
const padT = 6
const padB = 6
const units = opts.units ? String(opts.units) : ''
const padL = opts.padLeft ?? (opts.showYAxis !== false ? (units.length > 6 ? 52 : 46) : 0)
const padR = 10
const padT = 12
const padB = 10
const plotW = Math.max(1, w - padL - padR)
const plotH = Math.max(1, h - padT - padB)
if (opts.grid !== false) {
ctx.strokeStyle = 'rgba(154, 168, 188, 0.12)'
ctx.lineWidth = 1
for (let i = 1; i < 4; i++) {
const y = padT + (plotH / 4) * i
ctx.beginPath()
ctx.moveTo(padL, y)
ctx.lineTo(padL + plotW, y)
ctx.stroke()
}
}
const prepared = lines
.filter((l) => !l.hidden)
.map((l) => ({ ...l, values: (l.values || []).slice(-maxPoints) }))
.filter((l) => l.values.length >= 2)
const prepared = prepareSeries(lines, maxPoints)
if (!prepared.length) {
ctx.fillStyle = 'rgba(154, 168, 188, 0.55)'
ctx.font = '12px "Outfit", ui-sans-serif, system-ui, sans-serif'
const msg = opts.emptyMessage || 'No data'
const linesMsg = String(msg).split('\n')
linesMsg.forEach((line, i) => {
ctx.fillText(line, padL + 8, padT + 20 + i * 16)
})
drawEmpty(ctx, padL, padT, opts.emptyMessage)
return
}
if (opts.dimmed) ctx.globalAlpha = 0.72
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
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 (opts.threshold != null && Number.isFinite(opts.threshold)) {
drawThreshold(ctx, padL, plotW, yAt(opts.threshold), opts.severity)
}
if (stacked) {
drawStacked(ctx, prepared, xAt, yAt, maxPoints)
} else {
prepared.forEach((line) => {
const wantFill = mode === 'area' ? line.fill !== false : line.fill === true
drawLineSeries(ctx, line, xAt, yAt, padT, plotH, wantFill)
})
}
if (opts.hoverIndex != null && Number.isFinite(opts.hoverIndex)) {
drawHoverCrosshair(ctx, prepared, opts.hoverIndex, xAt, yAt, padT, plotH, maxPoints)
}
ctx.globalAlpha = 1
}
/**
* Time-series columns (bar) or grouped columns (multibar).
* @param {HTMLCanvasElement|null} canvas
* @param {Array<{ values: number[], color: string, label?: string, hidden?: boolean }>} lines
* @param {object} [opts]
*/
export function drawBarChart(canvas, lines, opts = {}) {
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = window.devicePixelRatio || 1
const w = canvas.clientWidth || 320
const h = canvas.clientHeight || canvas.height || 140
canvas.width = w * dpr
canvas.height = h * dpr
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)
const maxPoints = opts.maxPoints || 90
const units = opts.units ? String(opts.units) : ''
const padL = opts.padLeft ?? (units.length > 6 ? 52 : 46)
const padR = 10
const padT = 12
const padB = 10
const plotW = Math.max(1, w - padL - padR)
const plotH = Math.max(1, h - padT - padB)
const grouped = Boolean(opts.grouped)
let prepared = prepareSeries(lines, maxPoints)
// Multibar with many dims becomes unreadable — keep top series by latest |value|
if (grouped && prepared.length > 6) {
prepared = [...prepared]
.sort((a, b) => Math.abs(last(b.values)) - Math.abs(last(a.values)))
.slice(0, 6)
}
if (!prepared.length) {
drawEmpty(ctx, padL, padT, opts.emptyMessage)
return
}
if (opts.dimmed) ctx.globalAlpha = 0.72
const stackedBars = !grouped && prepared.length > 1
const { min, max, span } = computeScale(prepared, {
stacked: stackedBars,
threshold: opts.threshold,
})
const len = Math.max(...prepared.map((l) => l.values.length))
const yAt = (v) => padT + plotH - ((v - min) / span) * plotH
const y0 = yAt(Math.max(min, Math.min(max, 0)))
drawGrid(ctx, padL, padT, plotW, plotH, min, max, yAt)
drawYAxis(ctx, padL, padT, plotH, min, max, span, units)
const slot = plotW / Math.max(1, len)
const groupCount = grouped ? prepared.length : 1
const barW = Math.max(1.5, Math.min(14, (slot * 0.72) / groupCount))
for (let i = 0; i < len; i++) {
const cx = padL + slot * i + slot / 2
if (grouped) {
prepared.forEach((line, gi) => {
const v = line.values[i] ?? 0
const x = cx - (groupCount * barW) / 2 + gi * barW
const y = yAt(v)
ctx.fillStyle = withAlpha(line.color, i === opts.hoverIndex ? 0.95 : 0.75)
roundRect(ctx, x, Math.min(y, y0), barW - 0.5, Math.abs(y0 - y), 2)
ctx.fill()
})
} else if (stackedBars) {
let acc = 0
for (const line of prepared) {
const v = line.values[i] ?? 0
const y1 = yAt(acc)
const y2 = yAt(acc + v)
ctx.fillStyle = withAlpha(line.color, i === opts.hoverIndex ? 0.95 : 0.8)
roundRect(ctx, cx - barW / 2, Math.min(y1, y2), barW, Math.abs(y2 - y1), 1)
ctx.fill()
acc += v
}
} else {
const line = prepared[0]
const v = line.values[i] ?? 0
const y = yAt(v)
ctx.fillStyle = withAlpha(line.color, i === opts.hoverIndex ? 0.95 : 0.8)
roundRect(ctx, cx - barW / 2, Math.min(y, y0), barW, Math.abs(y0 - y), 2)
ctx.fill()
}
}
if (opts.hoverIndex != null && Number.isFinite(opts.hoverIndex)) {
const idx = clampIdx(opts.hoverIndex, len)
const x = padL + slot * idx + slot / 2
ctx.strokeStyle = 'rgba(226, 232, 240, 0.4)'
ctx.beginPath()
ctx.moveTo(x, padT)
ctx.lineTo(x, padT + plotH)
ctx.stroke()
}
ctx.globalAlpha = 1
}
/**
* Donut / pie of the latest sample (composition snapshot).
* @param {HTMLCanvasElement|null} canvas
* @param {Array<{ values: number[], color: string, label?: string, hidden?: boolean }>} lines
* @param {object} [opts]
*/
export function drawPieChart(canvas, lines, opts = {}) {
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = window.devicePixelRatio || 1
const w = canvas.clientWidth || 320
const h = canvas.clientHeight || canvas.height || 140
canvas.width = w * dpr
canvas.height = h * dpr
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)
const prepared = prepareSeries(lines, opts.maxPoints || 90)
const slices = prepared
.map((l) => ({
label: l.label || '',
color: l.color,
value: Math.abs(last(l.values)),
}))
.filter((s) => s.value > 0)
.sort((a, b) => b.value - a.value)
if (!slices.length) {
drawEmpty(ctx, 12, 16, opts.emptyMessage || 'No data')
return
}
if (opts.dimmed) ctx.globalAlpha = 0.72
const total = slices.reduce((s, x) => s + x.value, 0) || 1
const cx = w * 0.38
const cy = h / 2
const r = Math.min(w * 0.32, h * 0.42)
const inner = r * 0.58
let angle = -Math.PI / 2
for (const slice of slices) {
const sweep = (slice.value / total) * Math.PI * 2
ctx.beginPath()
ctx.arc(cx, cy, r, angle, angle + sweep, false)
ctx.arc(cx, cy, inner, angle + sweep, angle, true)
ctx.closePath()
ctx.fillStyle = slice.color
ctx.fill()
angle += sweep
}
// Center total
ctx.fillStyle = 'rgba(244, 247, 251, 0.9)'
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.font = '9px "IBM Plex Mono", ui-monospace, Menlo, monospace'
ctx.fillText(String(opts.units).slice(0, 12), cx, cy + 10)
}
// Side legend (top contributors)
const lx = w * 0.62
let ly = Math.max(14, cy - slices.slice(0, 6).length * 11)
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
for (const slice of slices.slice(0, 6)) {
const pct = ((slice.value / total) * 100).toFixed(0)
ctx.fillStyle = slice.color
ctx.beginPath()
ctx.arc(lx, ly, 3.5, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = 'rgba(200, 209, 223, 0.92)'
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)
ly += 16
}
ctx.globalAlpha = 1
}
/* ─── shared draw helpers ─── */
function prepareSeries(lines, maxPoints) {
return (lines || [])
.filter((l) => !l.hidden)
.map((l) => ({ ...l, values: (l.values || []).slice(-maxPoints) }))
.filter((l) => l.values.length >= 1)
.filter((l) => l.values.some((v) => Number.isFinite(v)))
}
function computeScale(prepared, { stacked, threshold }) {
let max = 1
let min = 0
if (opts.stacked) {
if (stacked) {
const len = Math.max(...prepared.map((l) => l.values.length))
for (let i = 0; i < len; i++) {
let sum = 0
for (const l of prepared) sum += l.values[i] ?? 0
max = Math.max(max, sum)
}
min = 0
} else {
const all = prepared.flatMap((l) => l.values)
max = Math.max(...all, 1e-9)
min = Math.min(...all, 0)
}
if (opts.threshold != null && Number.isFinite(opts.threshold)) {
max = Math.max(max, opts.threshold)
min = Math.min(min, opts.threshold)
if (threshold != null && Number.isFinite(threshold)) {
max = Math.max(max, threshold)
min = Math.min(min, threshold)
}
if (max > 0) max *= 1.05
const span = max - min || 1
return { min, max, span }
}
if (opts.showYAxis) {
ctx.fillStyle = 'rgba(154, 168, 188, 0.65)'
ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace'
ctx.textAlign = 'right'
ctx.textBaseline = 'middle'
for (let i = 0; i <= 4; i++) {
const v = max - (span * i) / 4
const y = padT + (plotH * i) / 4
ctx.fillText(formatAxis(v), padL - 4, y)
}
ctx.textAlign = 'left'
}
if (opts.threshold != null && Number.isFinite(opts.threshold)) {
const y = padT + plotH - ((opts.threshold - min) / span) * plotH
ctx.strokeStyle =
opts.severity === 'critical' ? 'rgba(248, 113, 113, 0.8)' : 'rgba(251, 191, 36, 0.8)'
ctx.setLineDash([5, 4])
ctx.lineWidth = 1.5
function drawGrid(ctx, padL, padT, plotW, plotH, min, max, yAt) {
for (let i = 0; i <= 4; i++) {
const y = padT + (plotH * i) / 4
ctx.strokeStyle = i === 4 ? 'rgba(154, 168, 188, 0.18)' : 'rgba(154, 168, 188, 0.1)'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(padL, y)
ctx.lineTo(padL + plotW, y)
ctx.stroke()
ctx.setLineDash([])
}
const xAt = (i, len) => padL + (i / Math.max(1, Math.max(maxPoints, len) - 1)) * plotW
const yAt = (v) => padT + plotH - ((v - min) / span) * plotH
if (opts.stacked) {
const len = Math.max(...prepared.map((l) => l.values.length))
/** @type {number[]} */
const acc = new Array(len).fill(0)
for (const line of prepared) {
ctx.beginPath()
for (let i = 0; i < len; i++) {
const v = (line.values[i] ?? 0) + acc[i]
const x = xAt(i, len)
const y = yAt(v)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
for (let i = len - 1; i >= 0; i--) {
const v = acc[i]
const x = xAt(i, len)
const y = yAt(v)
ctx.lineTo(x, y)
}
ctx.closePath()
ctx.fillStyle = withAlpha(line.color, 0.55)
ctx.fill()
for (let i = 0; i < len; i++) acc[i] += line.values[i] ?? 0
}
} else {
prepared.forEach((line, li) => {
const values = line.values
const len = values.length
ctx.strokeStyle = line.color
ctx.lineWidth = 2
ctx.beginPath()
values.forEach((v, i) => {
const x = xAt(i, len)
const y = yAt(v)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
})
ctx.stroke()
if ((line.fill || (li === 0 && prepared.length === 1)) && line.fill !== false) {
ctx.lineTo(xAt(len - 1, len), padT + plotH)
ctx.lineTo(xAt(0, len), padT + plotH)
ctx.closePath()
ctx.fillStyle = withAlpha(line.color, 0.16)
ctx.fill()
}
})
}
if (opts.hoverIndex != null && Number.isFinite(opts.hoverIndex)) {
const len = Math.max(...prepared.map((l) => l.values.length))
const idx = Math.max(0, Math.min(len - 1, Math.round(opts.hoverIndex)))
const x = xAt(idx, len)
ctx.strokeStyle = 'rgba(226, 232, 240, 0.45)'
ctx.lineWidth = 1
if (min <= 0 && max > 0) {
const y0 = yAt(0)
ctx.strokeStyle = 'rgba(154, 168, 188, 0.32)'
ctx.beginPath()
ctx.moveTo(x, padT)
ctx.lineTo(x, padT + plotH)
ctx.moveTo(padL, y0)
ctx.lineTo(padL + plotW, y0)
ctx.stroke()
for (const line of prepared) {
const v = line.values[idx]
if (v == null) continue
const y = yAt(v)
ctx.fillStyle = line.color
ctx.beginPath()
ctx.arc(x, y, 3.5, 0, Math.PI * 2)
ctx.fill()
}
}
ctx.globalAlpha = 1
}
function drawYAxis(ctx, padL, padT, plotH, min, max, span, units) {
ctx.fillStyle = 'rgba(154, 168, 188, 0.7)'
ctx.font = '10px "IBM Plex Mono", ui-monospace, Menlo, monospace'
ctx.textAlign = 'right'
ctx.textBaseline = 'middle'
for (let i = 0; i <= 4; i++) {
const v = max - (span * i) / 4
const y = padT + (plotH * i) / 4
ctx.fillText(formatAxis(v), padL - 5, y)
}
if (units) {
ctx.textAlign = 'left'
ctx.textBaseline = 'bottom'
ctx.fillStyle = 'rgba(45, 212, 191, 0.75)'
ctx.font = '9px "IBM Plex Mono", ui-monospace, Menlo, monospace'
ctx.fillText(units.length > 14 ? units.slice(0, 13) + '…' : units, padL + 2, padT - 1)
}
}
function drawThreshold(ctx, padL, plotW, y, severity) {
ctx.strokeStyle =
severity === 'critical' ? 'rgba(248, 113, 113, 0.85)' : 'rgba(251, 191, 36, 0.85)'
ctx.setLineDash([5, 4])
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.moveTo(padL, y)
ctx.lineTo(padL + plotW, y)
ctx.stroke()
ctx.setLineDash([])
}
function drawStacked(ctx, prepared, xAt, yAt, maxPoints) {
const len = Math.max(...prepared.map((l) => l.values.length))
/** @type {number[]} */
const acc = new Array(len).fill(0)
for (const line of prepared) {
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 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.closePath()
ctx.fillStyle = withAlpha(line.color, 0.5)
ctx.fill()
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 y = yAt(v)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.strokeStyle = withAlpha(line.color, 0.9)
ctx.lineWidth = 1.25
ctx.stroke()
for (let i = 0; i < len; i++) acc[i] += line.values[i] ?? 0
}
}
function drawLineSeries(ctx, line, xAt, yAt, padT, plotH, wantFill) {
const values = line.values
const len = values.length
if (wantFill) {
const grad = ctx.createLinearGradient(0, padT, 0, padT + plotH)
grad.addColorStop(0, withAlpha(line.color, 0.28))
grad.addColorStop(1, withAlpha(line.color, 0.02))
ctx.beginPath()
values.forEach((v, i) => {
const x = xAt(i, len)
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.closePath()
ctx.fillStyle = grad
ctx.fill()
}
ctx.strokeStyle = line.color
ctx.lineWidth = 2
ctx.lineJoin = 'round'
ctx.lineCap = 'round'
ctx.beginPath()
values.forEach((v, i) => {
const x = xAt(i, len)
const y = yAt(v)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
})
ctx.stroke()
}
function drawHoverCrosshair(ctx, prepared, hoverIndex, xAt, yAt, padT, plotH, maxPoints) {
const len = Math.max(...prepared.map((l) => l.values.length))
const idx = clampIdx(hoverIndex, len)
const x = xAt(idx, Math.max(maxPoints, len))
ctx.strokeStyle = 'rgba(226, 232, 240, 0.4)'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(x, padT)
ctx.lineTo(x, padT + plotH)
ctx.stroke()
for (const line of prepared) {
const v = line.values[idx]
if (v == null) continue
const y = yAt(v)
ctx.beginPath()
ctx.fillStyle = 'rgba(10, 12, 16, 0.85)'
ctx.arc(x, y, 5, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = line.color
ctx.beginPath()
ctx.arc(x, y, 3.25, 0, Math.PI * 2)
ctx.fill()
}
}
function drawEmpty(ctx, padL, padT, message) {
ctx.fillStyle = 'rgba(154, 168, 188, 0.5)'
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)
})
}
function roundRect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2)
if (h < 0.5) return
ctx.beginPath()
ctx.moveTo(x + rr, y)
ctx.arcTo(x + w, y, x + w, y + h, rr)
ctx.arcTo(x + w, y + h, x, y + h, rr)
ctx.arcTo(x, y + h, x, y, rr)
ctx.arcTo(x, y, x + w, y, rr)
ctx.closePath()
}
function last(arr) {
return arr?.length ? arr[arr.length - 1] : 0
}
function clampIdx(idx, len) {
return Math.max(0, Math.min(len - 1, Math.round(idx)))
}
/**
@@ -197,8 +502,8 @@ export function drawMultiChart(canvas, lines, opts = {}) {
export function hoverIndexFromEvent(canvas, clientX, opts = {}) {
const rect = canvas.getBoundingClientRect()
const w = rect.width || canvas.clientWidth || 320
const padL = opts.padLeft ?? (opts.showYAxis ? 44 : 0)
const padR = 8
const padL = opts.padLeft ?? (opts.showYAxis !== false ? 46 : 0)
const padR = 10
const plotW = Math.max(1, w - padL - padR)
const x = clientX - rect.left - padL
if (x < 0 || x > plotW) return null
@@ -209,14 +514,16 @@ export function hoverIndexFromEvent(canvas, clientX, opts = {}) {
}
/** @param {number} v */
function formatAxis(v) {
export function formatAxis(v) {
const a = Math.abs(v)
if (a >= 1e9) return (v / 1e9).toFixed(1) + 'G'
if (a >= 1e6) return (v / 1e6).toFixed(1) + 'M'
if (a >= 1e3) return (v / 1e3).toFixed(1) + 'k'
if (a >= 10) return v.toFixed(0)
if (a >= 1) return v.toFixed(1)
return v.toFixed(2)
if (a >= 100) return v.toFixed(0)
if (a >= 10) return v.toFixed(1)
if (a >= 1) return v.toFixed(2)
if (a >= 0.01) return v.toFixed(3)
return v.toFixed(4)
}
/** @param {string} color @param {number} alpha */
+175 -37
View File
@@ -3,7 +3,14 @@
*/
import { groupCatalog, TIME_PRESETS } from '../shared/taxonomy.js'
import { rankRelatedCharts } from '../shared/related-metrics.js'
import { drawMultiChart, hoverIndexFromEvent, pushDim, seriesColor } from './charts.js'
import {
CHART_MODE_LABEL,
chartModeHint,
defaultModeFromMeta,
nextChartMode,
normalizeChartMode,
} from '../shared/chart-types.js'
import { drawChart, hoverIndexFromEvent, pushDim, seriesColor } from './charts.js'
const GROUPS = ['average', 'min', 'max', 'sum']
@@ -11,6 +18,23 @@ const MIN_WINDOW = 30
const MAX_WINDOW = 21600
const DEFAULT_HEIGHT = 148
const HERO_HEIGHT = 200
const LEGEND_CHIP_CAP = 10
function scrollBehavior() {
try {
if (document.body?.dataset?.reduceMotion === '1') return 'auto'
if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) {
return 'auto'
}
} catch {
// ignore
}
return 'smooth'
}
function smoothScrollIntoView(el, block = 'start') {
el?.scrollIntoView({ behavior: scrollBehavior(), block })
}
/**
* @typedef {{
@@ -109,15 +133,14 @@ export function createMetricsDashboard(opts) {
function ensureCard(id, meta = {}) {
let card = state.cards.get(id)
const defaultMode =
String(meta.chartType || '').toLowerCase() === 'stacked' ? 'stacked' : 'line'
const defaultMode = defaultModeFromMeta(meta)
if (!card) {
card = {
dims: new Map(),
labels: [],
status: 'idle',
meta: meta || {},
mode: state.chartTypes.get(id) || defaultMode,
mode: normalizeChartMode(state.chartTypes.get(id) || defaultMode),
source: '',
}
state.cards.set(id, card)
@@ -487,23 +510,25 @@ export function createMetricsDashboard(opts) {
const height = cardHeightFor(id, card)
canvas.style.height = `${height}px`
const { dims, hidden } = sortedDims(card, id)
const mode = normalizeChartMode(card.mode || defaultModeFromMeta(card.meta))
card.mode = mode
const lines = []
let i = 0
for (const dim of dims) {
const values = card.dims.get(dim) || []
const mode = card.mode || 'line'
lines.push({
values,
color: seriesColor(i),
label: dim,
hidden: hidden.has(dim),
fill: mode === 'area' || mode === 'stacked' ? true : mode === 'line' ? false : undefined,
fill: mode === 'area' ? true : mode === 'line' ? false : undefined,
})
i++
}
const anomaly = opts.getAnomaly?.(id) || null
if (anomaly?.severity) article.dataset.severity = anomaly.severity
else delete article.dataset.severity
article.dataset.chartMode = mode
const emptyMsg =
card.status === 'loading'
? card.emptyReason || 'Loading…'
@@ -512,28 +537,40 @@ export function createMetricsDashboard(opts) {
: card.status === 'empty'
? card.emptyReason || 'No data'
: 'No data'
drawMultiChart(canvas, lines, {
const units = card.meta?.units || ''
drawChart(canvas, lines, {
mode,
maxPoints: maxPoints(),
stacked: card.mode === 'stacked',
showYAxis: true,
hoverIndex: state.hoverIndex,
threshold: anomaly?.threshold ?? null,
showYAxis: mode !== 'pie',
hoverIndex: mode === 'pie' ? null : state.hoverIndex,
threshold: mode === 'pie' ? null : anomaly?.threshold ?? null,
severity: anomaly?.severity || null,
emptyMessage: emptyMsg,
dimmed: Boolean(card.updating),
units,
})
updateCardTooltip(article, id, card, lines, units)
const statsEl = article.querySelector('.metric-card-stats')
if (statsEl && article.classList.contains('expanded')) {
statsEl.innerHTML = renderStatsHtml(card, dims, hidden)
statsEl.innerHTML = renderStatsHtml(card, dims, hidden, units)
}
if (legend && !card.updating) {
const nextHtml = lines
.map((line) => {
const last = line.values[line.values.length - 1]
const text = `${line.label}${last != null ? ` ${formatVal(last)}` : ''}`
return `<button type="button" class="dim-chip${line.hidden ? ' off' : ''}" style="--dim-color:${line.color}" data-dim="${escapeAttr(line.label)}" title="Toggle dimension">${escapeHtml(text)}</button>`
})
.join('')
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}` : ''
const nextHtml =
show
.map((line) => {
const last = line.values[line.values.length - 1]
const text = `${line.label}${last != null ? ` ${formatVal(last)}${unitSuffix}` : ''}`
return `<button type="button" class="dim-chip${line.hidden ? ' off' : ''}" style="--dim-color:${line.color}" data-dim="${escapeAttr(line.label)}" title="Toggle dimension">${escapeHtml(text)}</button>`
})
.join('') +
(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
@@ -547,6 +584,93 @@ export function createMetricsDashboard(opts) {
}
}
/**
* Per-card hover tooltip with values + units.
* @param {HTMLElement} article
* @param {string} id
* @param {object} card
* @param {Array<{ label: string, values: number[], color: string, hidden?: boolean }>} lines
* @param {string} units
*/
function updateCardTooltip(article, id, card, lines, units) {
let tip = article.querySelector('.chart-hover-tip')
if (!tip) {
tip = document.createElement('div')
tip.className = 'chart-hover-tip hidden'
tip.setAttribute('aria-hidden', 'true')
article.style.position = 'relative'
article.appendChild(tip)
}
const mode = normalizeChartMode(card.mode)
const unitSuffix = units ? ` ${escapeHtml(units)}` : ''
// Pie = snapshot of latest values (always show while card hovered via canvas leave/enter)
if (mode === 'pie') {
if (!state.visible.has(id) || !article.matches(':hover')) {
tip.classList.add('hidden')
article.classList.remove('is-hovering')
return
}
const rows = lines
.filter((l) => !l.hidden)
.map((l) => ({
label: l.label,
value: l.values[l.values.length - 1],
color: l.color,
}))
.filter((r) => r.value != null && Number.isFinite(r.value) && Math.abs(r.value) > 0)
.sort((a, b) => Math.abs(b.value) - Math.abs(a.value))
const total = rows.reduce((s, r) => s + Math.abs(r.value), 0) || 1
tip.innerHTML =
`<header>Share · latest</header>` +
rows
.slice(0, 8)
.map((r) => {
const pct = ((Math.abs(r.value) / total) * 100).toFixed(0)
return `<div class="tip-row"><i style="background:${r.color}"></i><span>${escapeHtml(r.label)}</span><strong>${pct}% · ${escapeHtml(formatVal(r.value))}${unitSuffix}</strong></div>`
})
.join('')
tip.classList.remove('hidden')
article.classList.add('is-hovering')
return
}
if (state.hoverIndex == null || !state.visible.has(id)) {
tip.classList.add('hidden')
article.classList.remove('is-hovering')
return
}
const idx = Math.round(state.hoverIndex)
const rows = lines
.filter((l) => !l.hidden)
.map((l) => {
const vals = l.values || []
const i = Math.max(0, Math.min(vals.length - 1, idx))
return { label: l.label, value: vals[i], color: l.color }
})
.filter((r) => r.value != null && Number.isFinite(r.value))
.sort((a, b) => Math.abs(b.value) - Math.abs(a.value))
.slice(0, 8)
if (!rows.length) {
tip.classList.add('hidden')
return
}
const frac = maxPoints() > 1 ? idx / (maxPoints() - 1) : 1
const age = state.endOffset + state.afterSeconds * (1 - frac)
tip.innerHTML =
`<header>${escapeHtml(formatDuration(Math.max(0, age)))} ago</header>` +
rows
.map(
(r) =>
`<div class="tip-row"><i style="background:${r.color}"></i><span>${escapeHtml(r.label)}</span><strong>${escapeHtml(formatVal(r.value))}${unitSuffix}</strong></div>`
)
.join('')
tip.classList.remove('hidden')
article.classList.add('is-hovering')
}
function cardHeightFor(id, card) {
const base = state.cardHeight || DEFAULT_HEIGHT
if (state.pinned.has(id)) return Math.max(base, HERO_HEIGHT)
@@ -575,13 +699,15 @@ export function createMetricsDashboard(opts) {
function cycleChartType(id) {
const card = state.cards.get(id)
if (!card) return
const order = ['line', 'area', 'stacked']
const next = order[(order.indexOf(card.mode) + 1) % order.length]
const next = nextChartMode(card.mode, { ...card.meta, id, dimensions: card.labels })
card.mode = next
state.chartTypes.set(id, next)
persistPrefs({ chartTypes: Object.fromEntries(state.chartTypes) })
const typeBtn = state.cardEls.get(id)?.querySelector('.metric-type-btn')
if (typeBtn) typeBtn.textContent = next
if (typeBtn) {
typeBtn.textContent = CHART_MODE_LABEL[next] || next
typeBtn.title = chartModeHint(next)
}
paintCard(id)
}
@@ -662,7 +788,7 @@ export function createMetricsDashboard(opts) {
}
const frac = maxPoints() > 1 ? state.hoverIndex / (maxPoints() - 1) : 0
const age = state.endOffset + state.afterSeconds * (1 - frac)
opts.els.hoverReadout.textContent = `cursor · ~${formatDuration(Math.max(0, age))} ago`
opts.els.hoverReadout.textContent = `~${formatDuration(Math.max(0, age))} ago`
}
function bindCanvasInteractions(canvas, chartId) {
@@ -686,6 +812,8 @@ export function createMetricsDashboard(opts) {
canvas.addEventListener(
'wheel',
(ev) => {
const card = state.cards.get(chartId)
if (normalizeChartMode(card?.mode) === 'pie') return
ev.preventDefault()
const factor = ev.deltaY > 0 ? 1.25 : 0.8
setWindow(Math.round(state.afterSeconds * factor), state.endOffset)
@@ -695,6 +823,8 @@ export function createMetricsDashboard(opts) {
canvas.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0) return
if (state.forcePlay) return
const card = state.cards.get(chartId)
if (normalizeChartMode(card?.mode) === 'pie') return
// Drag to pan time (pause + shift window end)
canvas.setPointerCapture(ev.pointerId)
state.pan = { active: true, startX: ev.clientX, startOffset: state.endOffset }
@@ -761,10 +891,8 @@ export function createMetricsDashboard(opts) {
pinBtn.className = 'toc-item'
pinBtn.innerHTML = `<span>Pinned</span><span class="toc-count">${state.pinned.size}</span>`
pinBtn.addEventListener('click', () => {
opts.els.wall?.querySelector('[data-section="pinned"]')?.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
const el = opts.els.wall?.querySelector('[data-section="pinned"]')
if (el) smoothScrollIntoView(el, 'start')
})
toc.appendChild(pinBtn)
}
@@ -782,7 +910,7 @@ export function createMetricsDashboard(opts) {
btn.innerHTML = `<span>${escapeHtml(sec.title)}</span><span class="toc-count">${sec.count}</span>`
btn.addEventListener('click', () => {
const el = opts.els.wall?.querySelector(`[data-section="${sec.id}"]`)
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
if (el) smoothScrollIntoView(el, 'start')
})
toc.appendChild(btn)
for (const g of sec.groups.slice(0, 12)) {
@@ -799,7 +927,7 @@ export function createMetricsDashboard(opts) {
(n) => n.getAttribute('data-family') === g.family
)
: null
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
if (el) smoothScrollIntoView(el, 'start')
})
toc.appendChild(sub)
}
@@ -996,7 +1124,7 @@ export function createMetricsDashboard(opts) {
<div class="metric-card-actions">
<button type="button" class="btn btn-ghost metric-pin-btn" title="Pin">${state.pinned.has(id) ? '★' : '☆'}</button>
<button type="button" class="btn btn-ghost metric-related-btn" title="Find related"></button>
<button type="button" class="btn btn-ghost metric-type-btn" title="Chart type">${escapeHtml(card.mode)}</button>
<button type="button" class="btn btn-ghost metric-type-btn" title="${escapeAttr(chartModeHint(card.mode))}">${escapeHtml(CHART_MODE_LABEL[card.mode] || card.mode)}</button>
<span class="metric-card-status muted"></span>
</div>
</header>
@@ -1010,6 +1138,14 @@ export function createMetricsDashboard(opts) {
canvas.style.height = `${height}px`
bindCanvasInteractions(canvas, id)
}
article.addEventListener('mouseenter', () => {
if (normalizeChartMode(card.mode) === 'pie') paintCard(id)
})
article.addEventListener('mouseleave', () => {
const tip = article.querySelector('.chart-hover-tip')
tip?.classList.add('hidden')
article.classList.remove('is-hovering')
})
article.querySelector('.metric-pin-btn')?.addEventListener('click', (ev) => {
ev.stopPropagation()
togglePin(id)
@@ -1031,7 +1167,7 @@ export function createMetricsDashboard(opts) {
if (article.classList.contains('expanded')) {
statsEl.classList.remove('hidden')
const { dims, hidden } = sortedDims(card, id)
statsEl.innerHTML = renderStatsHtml(card, dims, hidden)
statsEl.innerHTML = renderStatsHtml(card, dims, hidden, card.meta?.units || '')
} else {
statsEl.classList.add('hidden')
}
@@ -1231,7 +1367,7 @@ export function createMetricsDashboard(opts) {
function scrollToChart(id) {
const el = state.cardEls.get(id)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
smoothScrollIntoView(el, 'center')
el.classList.add('flash')
setTimeout(() => el.classList.remove('flash'), 1200)
return
@@ -1241,9 +1377,10 @@ export function createMetricsDashboard(opts) {
render()
requestAnimationFrame(() => {
const again = state.cardEls.get(id)
again?.scrollIntoView({ behavior: 'smooth', block: 'center' })
again?.classList.add('flash')
setTimeout(() => again?.classList.remove('flash'), 1200)
if (!again) return
smoothScrollIntoView(again, 'center')
again.classList.add('flash')
setTimeout(() => again.classList.remove('flash'), 1200)
})
}
@@ -1336,7 +1473,8 @@ function formatDuration(sec) {
* @param {string[]} dims
* @param {Set<string>} hidden
*/
function renderStatsHtml(card, dims, hidden) {
function renderStatsHtml(card, dims, hidden, units = '') {
const u = units ? ` <small class="muted">${escapeHtml(units)}</small>` : ''
const rows = dims
.filter((d) => !hidden.has(d))
.map((d) => {
@@ -1352,7 +1490,7 @@ function renderStatsHtml(card, dims, hidden) {
}
const avg = sum / arr.length
const last = arr[arr.length - 1]
return `<tr><td>${escapeHtml(d)}</td><td>${formatVal(last)}</td><td>${formatVal(min)}</td><td>${formatVal(avg)}</td><td>${formatVal(max)}</td></tr>`
return `<tr><td>${escapeHtml(d)}</td><td>${formatVal(last)}${u}</td><td>${formatVal(min)}</td><td>${formatVal(avg)}</td><td>${formatVal(max)}</td></tr>`
})
.filter(Boolean)
if (!rows.length) return '<p class="muted">No dimension stats</p>'
+237 -6
View File
@@ -148,6 +148,111 @@ body[data-reduce-motion='1'] *::after {
transition-duration: 0.01ms !important;
}
/* ─── Mini scrollbars ─── */
:root {
--scrollbar-size: 6px;
--scrollbar-thumb: rgba(255, 255, 255, 0.16);
--scrollbar-thumb-hover: color-mix(in srgb, var(--accent-primary) 55%, rgba(255, 255, 255, 0.35));
}
html[data-theme='light'] {
--scrollbar-thumb: rgba(15, 23, 42, 0.2);
--scrollbar-thumb-hover: color-mix(in srgb, var(--accent-primary) 45%, rgba(15, 23, 42, 0.35));
}
/* Main app pane — slim, styled */
#content {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) transparent;
}
#content::-webkit-scrollbar {
width: 6px;
height: 6px;
}
#content::-webkit-scrollbar-track {
background: transparent;
margin: 4px 0;
}
#content::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 999px;
border: 1px solid transparent;
background-clip: padding-box;
min-height: 40px;
}
#content::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
background-clip: padding-box;
}
#content::-webkit-scrollbar-corner {
background: transparent;
}
.metrics-wall,
.metrics-toc-nav,
.metrics-toc,
.metric-card-stats,
#sidebar,
.fleet-cards,
.event-list,
.log,
.invite-out {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) transparent;
}
.metrics-wall::-webkit-scrollbar,
.metrics-toc-nav::-webkit-scrollbar,
.metrics-toc::-webkit-scrollbar,
.metric-card-stats::-webkit-scrollbar,
#sidebar::-webkit-scrollbar,
.fleet-cards::-webkit-scrollbar,
.event-list::-webkit-scrollbar,
.log::-webkit-scrollbar {
width: var(--scrollbar-size);
height: var(--scrollbar-size);
}
.metrics-wall::-webkit-scrollbar-track,
.metrics-toc-nav::-webkit-scrollbar-track,
.metrics-toc::-webkit-scrollbar-track,
.metric-card-stats::-webkit-scrollbar-track,
#sidebar::-webkit-scrollbar-track,
.fleet-cards::-webkit-scrollbar-track,
.event-list::-webkit-scrollbar-track,
.log::-webkit-scrollbar-track {
background: transparent;
}
.metrics-wall::-webkit-scrollbar-thumb,
.metrics-toc-nav::-webkit-scrollbar-thumb,
.metrics-toc::-webkit-scrollbar-thumb,
.metric-card-stats::-webkit-scrollbar-thumb,
#sidebar::-webkit-scrollbar-thumb,
.fleet-cards::-webkit-scrollbar-thumb,
.event-list::-webkit-scrollbar-thumb,
.log::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 999px;
border: 1px solid transparent;
background-clip: padding-box;
}
.metrics-wall::-webkit-scrollbar-thumb:hover,
.metrics-toc-nav::-webkit-scrollbar-thumb:hover,
#sidebar::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
background-clip: padding-box;
}
@media (prefers-reduced-motion: reduce) {
.metrics-wall {
scroll-behavior: auto !important;
}
}
body[data-reduce-motion='1'] .metrics-wall {
scroll-behavior: auto !important;
}
/* ─── Titlebar ─── */
#titlebar {
grid-area: titlebar;
@@ -384,8 +489,17 @@ body.sidebar-collapsed #conn-meta {
#content {
grid-area: content;
overflow: auto;
overflow-x: hidden;
padding: var(--space-lg);
padding-right: calc(var(--space-lg) - 2px);
min-width: 0;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
}
body[data-reduce-motion='1'] #content {
scroll-behavior: auto;
}
.view.hidden,
@@ -397,6 +511,15 @@ body.sidebar-collapsed #conn-meta {
animation: viewIn 0.28s ease;
}
@media (prefers-reduced-motion: reduce) {
#content {
scroll-behavior: auto;
}
.view {
animation: none;
}
}
@keyframes viewIn {
from {
opacity: 0;
@@ -569,7 +692,12 @@ body.is-offline .offline-banner:not(.hidden) {
.chart-panel canvas {
width: 100%;
display: block;
border-radius: 8px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.18);
}
html[data-theme='light'] .chart-panel canvas {
background: rgba(15, 23, 42, 0.03);
}
.chart-legend {
@@ -1055,8 +1183,10 @@ button.metrics-tf:disabled {
.metrics-wall {
min-height: 0;
overflow: auto;
padding-right: 4px;
padding-right: 2px;
scroll-behavior: smooth;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.metrics-empty {
@@ -1066,6 +1196,7 @@ button.metrics-tf:disabled {
.metrics-section {
margin-bottom: 28px;
scroll-margin-top: 8px;
}
.metrics-section.collapsed .metrics-section-body {
@@ -1079,13 +1210,14 @@ button.metrics-tf:disabled {
position: sticky;
top: 0;
z-index: 2;
padding: 8px 0;
padding: 10px 0 8px;
background: linear-gradient(
to bottom,
var(--bg-primary) 70%,
color-mix(in srgb, var(--bg-primary) 92%, transparent) 55%,
transparent
);
backdrop-filter: blur(6px);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.metrics-section-head h2 {
@@ -1134,7 +1266,32 @@ button.metrics-tf:disabled {
var(--bg-secondary);
border: 1px solid var(--border-color);
min-height: 0;
transition: border-color 0.15s ease, opacity 0.2s ease;
scroll-margin-top: 52px;
transition:
border-color 0.18s ease,
opacity 0.2s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
}
.metric-card:hover {
border-color: color-mix(in srgb, var(--accent-primary) 28%, var(--border-color));
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.22);
}
.metric-card.is-hovering {
border-color: color-mix(in srgb, var(--accent-primary) 42%, var(--border-color));
}
.metric-card[data-chart-mode='pie'] canvas {
cursor: default;
}
.metric-type-btn {
text-transform: lowercase;
min-width: 3.4rem !important;
font-family: var(--font-mono) !important;
font-size: 11px !important;
}
.metric-card--hero {
@@ -1227,6 +1384,80 @@ html[data-theme='light'] .metric-card canvas {
text-decoration: line-through;
}
.dim-chip-more {
font-size: 11px;
font-family: var(--font-mono);
padding: 3px 6px;
align-self: center;
}
.metric-card.is-hovering .dim-chip {
opacity: 0.45;
}
.metric-card.is-hovering .dim-chip:hover {
opacity: 1;
}
.chart-hover-tip {
position: absolute;
top: 44px;
right: 12px;
z-index: 6;
min-width: 140px;
max-width: min(280px, 70%);
padding: 8px 10px;
border-radius: 10px;
background: color-mix(in srgb, var(--bg-elevated) 94%, transparent);
border: 1px solid var(--border-strong);
box-shadow: var(--shadow-md);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
pointer-events: none;
font-size: 11px;
line-height: 1.35;
}
.chart-hover-tip.hidden {
display: none;
}
.chart-hover-tip header {
font-family: var(--font-mono);
color: var(--text-muted);
margin-bottom: 6px;
font-size: 10px;
letter-spacing: 0.02em;
}
.chart-hover-tip .tip-row {
display: grid;
grid-template-columns: 8px 1fr auto;
gap: 8px;
align-items: center;
margin-top: 3px;
}
.chart-hover-tip .tip-row i {
width: 8px;
height: 8px;
border-radius: 50%;
display: block;
}
.chart-hover-tip .tip-row span {
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chart-hover-tip .tip-row strong {
font-family: var(--font-mono);
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
}
.metric-card.flash {
box-shadow: 0 0 0 1px rgba(52, 211, 153, 0.55), 0 0 24px rgba(52, 211, 153, 0.18);
}