Updates
This commit is contained in:
+154
-33
@@ -4,13 +4,17 @@
|
||||
|
||||
/**
|
||||
* @param {HTMLCanvasElement|null} canvas
|
||||
* @param {Array<{ values: number[], color: string, label?: string, fill?: boolean }>} lines
|
||||
* @param {Array<{ values: number[], color: string, label?: string, fill?: boolean, hidden?: boolean }>} lines
|
||||
* @param {{
|
||||
* threshold?: number|null,
|
||||
* severity?: string|null,
|
||||
* stacked?: boolean,
|
||||
* maxPoints?: number,
|
||||
* grid?: boolean,
|
||||
* hoverIndex?: number|null,
|
||||
* padLeft?: number,
|
||||
* showYAxis?: boolean,
|
||||
* units?: string,
|
||||
* }} [opts]
|
||||
*/
|
||||
export function drawMultiChart(canvas, lines, opts = {}) {
|
||||
@@ -19,29 +23,42 @@ export function drawMultiChart(canvas, lines, opts = {}) {
|
||||
if (!ctx) return
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const w = canvas.clientWidth || 320
|
||||
const h = canvas.height || 140
|
||||
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 padL = opts.padLeft ?? (opts.showYAxis ? 44 : 0)
|
||||
const padR = 8
|
||||
const padT = 6
|
||||
const padB = 6
|
||||
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 = (h / 4) * i
|
||||
const y = padT + (plotH / 4) * i
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, y)
|
||||
ctx.lineTo(w, y)
|
||||
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)
|
||||
if (!prepared.length) return
|
||||
if (!prepared.length) {
|
||||
ctx.fillStyle = 'rgba(154, 168, 188, 0.45)'
|
||||
ctx.font = '12px ui-sans-serif, system-ui, sans-serif'
|
||||
ctx.fillText('No data', padL + 8, padT + 20)
|
||||
return
|
||||
}
|
||||
|
||||
let max = 1
|
||||
let min = 0
|
||||
@@ -54,7 +71,7 @@ export function drawMultiChart(canvas, lines, opts = {}) {
|
||||
}
|
||||
} else {
|
||||
const all = prepared.flatMap((l) => l.values)
|
||||
max = Math.max(...all, 1)
|
||||
max = Math.max(...all, 1e-9)
|
||||
min = Math.min(...all, 0)
|
||||
}
|
||||
if (opts.threshold != null && Number.isFinite(opts.threshold)) {
|
||||
@@ -63,19 +80,35 @@ export function drawMultiChart(canvas, lines, opts = {}) {
|
||||
}
|
||||
const span = max - min || 1
|
||||
|
||||
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 = h - ((opts.threshold - min) / span) * (h - 8) - 4
|
||||
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
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, y)
|
||||
ctx.lineTo(w, y)
|
||||
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[]} */
|
||||
@@ -84,45 +117,109 @@ export function drawMultiChart(canvas, lines, opts = {}) {
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i < len; i++) {
|
||||
const v = (line.values[i] ?? 0) + acc[i]
|
||||
const x = (i / Math.max(1, maxPoints - 1)) * w
|
||||
const y = h - ((v - min) / span) * (h - 8) - 4
|
||||
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 = (i / Math.max(1, maxPoints - 1)) * w
|
||||
const y = h - ((v - min) / span) * (h - 8) - 4
|
||||
const x = xAt(i, len)
|
||||
const y = yAt(v)
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = line.color + (line.color.length === 7 ? '99' : '')
|
||||
ctx.fillStyle = withAlpha(line.color, 0.55)
|
||||
ctx.fill()
|
||||
for (let i = 0; i < len; i++) acc[i] += line.values[i] ?? 0
|
||||
}
|
||||
return
|
||||
} 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
prepared.forEach((line, li) => {
|
||||
const values = line.values
|
||||
ctx.strokeStyle = line.color
|
||||
ctx.lineWidth = 2
|
||||
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
|
||||
ctx.beginPath()
|
||||
values.forEach((v, i) => {
|
||||
const x = (i / Math.max(1, maxPoints - 1)) * w
|
||||
const y = h - ((v - min) / span) * (h - 8) - 4
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
})
|
||||
ctx.moveTo(x, padT)
|
||||
ctx.lineTo(x, padT + plotH)
|
||||
ctx.stroke()
|
||||
if ((line.fill || (li === 0 && prepared.length === 1)) && line.fill !== false) {
|
||||
ctx.lineTo(w, h)
|
||||
ctx.lineTo(0, h)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = line.color.length === 7 ? line.color + '28' : line.color
|
||||
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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]
|
||||
*/
|
||||
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 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))
|
||||
return Math.max(0, Math.min(len - 1, idx))
|
||||
}
|
||||
|
||||
/** @param {number} v */
|
||||
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)
|
||||
}
|
||||
|
||||
/** @param {string} color @param {number} alpha */
|
||||
function withAlpha(color, alpha) {
|
||||
if (!color) return `rgba(154,168,188,${alpha})`
|
||||
if (color.startsWith('#') && color.length === 7) {
|
||||
const r = parseInt(color.slice(1, 3), 16)
|
||||
const g = parseInt(color.slice(3, 5), 16)
|
||||
const b = parseInt(color.slice(5, 7), 16)
|
||||
return `rgba(${r},${g},${b},${alpha})`
|
||||
}
|
||||
return color
|
||||
}
|
||||
|
||||
export const CHART_PALETTE = {
|
||||
@@ -136,7 +233,15 @@ export const CHART_PALETTE = {
|
||||
disk: '#fbbf24',
|
||||
load: '#a78bfa',
|
||||
explore: '#fb7185',
|
||||
compare: ['#34d399', '#2dd4bf', '#38bdf8', '#fbbf24', '#fb7185'],
|
||||
compare: ['#34d399', '#2dd4bf', '#38bdf8', '#fbbf24', '#fb7185', '#a78bfa', '#f472b6', '#94a3b8'],
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} i
|
||||
*/
|
||||
export function seriesColor(i) {
|
||||
const pal = CHART_PALETTE.compare
|
||||
return pal[i % pal.length]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,3 +253,19 @@ export function pushRing(arr, value, max) {
|
||||
arr.push(Number(value) || 0)
|
||||
while (arr.length > max) arr.shift()
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a value into a named dimension ring map.
|
||||
* @param {Map<string, number[]>} dimMap
|
||||
* @param {string} dim
|
||||
* @param {number} value
|
||||
* @param {number} max
|
||||
*/
|
||||
export function pushDim(dimMap, dim, value, max) {
|
||||
let arr = dimMap.get(dim)
|
||||
if (!arr) {
|
||||
arr = []
|
||||
dimMap.set(dim, arr)
|
||||
}
|
||||
pushRing(arr, value, max)
|
||||
}
|
||||
|
||||
+520
@@ -0,0 +1,520 @@
|
||||
/**
|
||||
* Master metrics dashboard — sectioned TOC + long-scroll chart wall.
|
||||
*/
|
||||
import { groupCatalog, TIME_PRESETS } from '../shared/taxonomy.js'
|
||||
import { drawMultiChart, hoverIndexFromEvent, pushDim, seriesColor } from './charts.js'
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* root: HTMLElement,
|
||||
* toc: HTMLElement,
|
||||
* wall: HTMLElement,
|
||||
* search: HTMLInputElement|null,
|
||||
* playBtn: HTMLButtonElement|null,
|
||||
* presets: HTMLElement|null,
|
||||
* meta: HTMLElement|null,
|
||||
* hoverReadout: HTMLElement|null,
|
||||
* }} DashboardEls
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* els: DashboardEls,
|
||||
* getCatalog: () => Record<string, object>,
|
||||
* queryData: (args: object) => Promise<object>,
|
||||
* getPoints: () => number,
|
||||
* onSelectChart?: (id: string) => void,
|
||||
* }} opts
|
||||
*/
|
||||
export function createMetricsDashboard(opts) {
|
||||
const state = {
|
||||
filter: '',
|
||||
playing: true,
|
||||
presetId: '5m',
|
||||
afterSeconds: 300,
|
||||
hoverIndex: /** @type {number|null} */ (null),
|
||||
collapsed: /** @type {Set<string>} */ (new Set()),
|
||||
hiddenDims: /** @type {Map<string, Set<string>>} */ (new Map()),
|
||||
/** @type {Map<string, { dims: Map<string, number[]>, labels: string[], status: string, meta: object, stacked: boolean }>} */
|
||||
cards: new Map(),
|
||||
/** @type {Map<string, HTMLElement>} */
|
||||
cardEls: new Map(),
|
||||
visible: /** @type {Set<string>} */ (new Set()),
|
||||
observer: /** @type {IntersectionObserver|null} */ (null),
|
||||
built: false,
|
||||
refetchTimer: /** @type {ReturnType<typeof setTimeout>|null} */ (null),
|
||||
}
|
||||
|
||||
function points() {
|
||||
const fromPreset = Math.max(30, Math.min(600, Math.round(state.afterSeconds)))
|
||||
const setting = opts.getPoints?.() || 90
|
||||
// Prefer denser of setting vs ~1Hz for the window, capped
|
||||
return Math.min(600, Math.max(setting, Math.min(fromPreset, state.afterSeconds)))
|
||||
}
|
||||
|
||||
function maxPoints() {
|
||||
return points()
|
||||
}
|
||||
|
||||
function ensureCard(id, meta = {}) {
|
||||
let card = state.cards.get(id)
|
||||
if (!card) {
|
||||
card = {
|
||||
dims: new Map(),
|
||||
labels: [],
|
||||
status: 'idle',
|
||||
meta: meta || {},
|
||||
stacked: String(meta.chartType || '').toLowerCase() === 'stacked',
|
||||
}
|
||||
state.cards.set(id, card)
|
||||
} else if (meta && Object.keys(meta).length) {
|
||||
card.meta = { ...card.meta, ...meta }
|
||||
card.stacked = String(card.meta.chartType || '').toLowerCase() === 'stacked'
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
function setPlaying(on) {
|
||||
state.playing = Boolean(on)
|
||||
if (opts.els.playBtn) {
|
||||
opts.els.playBtn.textContent = state.playing ? 'Pause' : 'Play'
|
||||
opts.els.playBtn.setAttribute('aria-pressed', state.playing ? 'true' : 'false')
|
||||
}
|
||||
}
|
||||
|
||||
function setPreset(id) {
|
||||
const p = TIME_PRESETS.find((x) => x.id === id) || TIME_PRESETS[1]
|
||||
state.presetId = p.id
|
||||
state.afterSeconds = p.seconds
|
||||
if (opts.els.presets) {
|
||||
opts.els.presets.querySelectorAll('[data-preset]').forEach((btn) => {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-preset') === p.id)
|
||||
})
|
||||
}
|
||||
scheduleRefetchVisible()
|
||||
}
|
||||
|
||||
function scheduleRefetchVisible() {
|
||||
if (state.refetchTimer) clearTimeout(state.refetchTimer)
|
||||
state.refetchTimer = setTimeout(() => {
|
||||
for (const id of state.visible) fetchChart(id).catch(() => {})
|
||||
}, 80)
|
||||
}
|
||||
|
||||
async function fetchChart(id) {
|
||||
const catalog = opts.getCatalog() || {}
|
||||
const meta = catalog[id] || state.cards.get(id)?.meta || {}
|
||||
const card = ensureCard(id, meta)
|
||||
card.status = 'loading'
|
||||
paintCard(id)
|
||||
try {
|
||||
const q = await opts.queryData({
|
||||
chart: id,
|
||||
after: -state.afterSeconds,
|
||||
points: points(),
|
||||
})
|
||||
const labels = Array.isArray(q.labels)
|
||||
? q.labels.filter((l) => l && l !== 'time')
|
||||
: []
|
||||
// query rows: [time, d0, d1, ...] — discover dim names
|
||||
let dimNames = labels.length
|
||||
? labels
|
||||
: inferDimNames(meta, q.data?.[0]?.length ? q.data[0].length - 1 : 0)
|
||||
if (!dimNames.length && q.data?.[0]) {
|
||||
const n = q.data[0].length - 1
|
||||
dimNames = Array.from({ length: Math.max(0, n) }, (_, i) => `d${i}`)
|
||||
}
|
||||
card.labels = dimNames
|
||||
card.dims = new Map()
|
||||
const max = maxPoints()
|
||||
for (const name of dimNames) card.dims.set(name, [])
|
||||
for (const row of q.data || []) {
|
||||
for (let i = 0; i < dimNames.length; i++) {
|
||||
const v = row[i + 1]
|
||||
pushDim(card.dims, dimNames[i], Number(v) || 0, max)
|
||||
}
|
||||
}
|
||||
card.status = card.dims.size ? 'ok' : 'empty'
|
||||
} catch (err) {
|
||||
card.status = 'error'
|
||||
card.meta = { ...card.meta, error: err?.message || 'query failed' }
|
||||
}
|
||||
paintCard(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} meta
|
||||
* @param {number} n
|
||||
*/
|
||||
function inferDimNames(meta, n) {
|
||||
const dims = meta.dimensions
|
||||
if (Array.isArray(dims) && dims.length) {
|
||||
return dims.map((d) => (typeof d === 'string' ? d : d.id || d.name)).filter(Boolean)
|
||||
}
|
||||
return Array.from({ length: n }, (_, i) => `d${i}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Live sample ingest for watched/visible charts.
|
||||
* @param {Array<{ chart: string, values: Record<string, number> }>} samples
|
||||
*/
|
||||
function onSamples(samples) {
|
||||
if (!state.playing || !state.built) return
|
||||
const max = maxPoints()
|
||||
let any = false
|
||||
for (const s of samples || []) {
|
||||
if (!s?.chart || !s.values) continue
|
||||
if (!state.visible.has(s.chart) && !state.cards.has(s.chart)) continue
|
||||
if (!state.visible.has(s.chart)) continue
|
||||
const catalog = opts.getCatalog() || {}
|
||||
const card = ensureCard(s.chart, catalog[s.chart] || {})
|
||||
for (const [dim, val] of Object.entries(s.values)) {
|
||||
if (typeof val !== 'number') continue
|
||||
pushDim(card.dims, dim, val, max)
|
||||
if (!card.labels.includes(dim)) card.labels.push(dim)
|
||||
}
|
||||
card.status = 'ok'
|
||||
paintCard(s.chart)
|
||||
any = true
|
||||
}
|
||||
if (any) updateHoverReadout()
|
||||
}
|
||||
|
||||
function paintCard(id) {
|
||||
const article = state.cardEls.get(id)
|
||||
if (!article) return
|
||||
const card = state.cards.get(id)
|
||||
const canvas = article.querySelector('canvas')
|
||||
const statusEl = article.querySelector('.metric-card-status')
|
||||
const legend = article.querySelector('.metric-card-legend')
|
||||
if (statusEl) {
|
||||
statusEl.textContent =
|
||||
card?.status === 'loading'
|
||||
? 'loading'
|
||||
: card?.status === 'error'
|
||||
? 'error'
|
||||
: card?.status === 'empty'
|
||||
? 'empty'
|
||||
: ''
|
||||
}
|
||||
if (!card || !canvas) return
|
||||
const hidden = state.hiddenDims.get(id) || new Set()
|
||||
const lines = []
|
||||
let i = 0
|
||||
for (const dim of card.labels.length ? card.labels : [...card.dims.keys()]) {
|
||||
const values = card.dims.get(dim) || []
|
||||
lines.push({
|
||||
values,
|
||||
color: seriesColor(i),
|
||||
label: dim,
|
||||
hidden: hidden.has(dim),
|
||||
fill: card.stacked ? true : undefined,
|
||||
})
|
||||
i++
|
||||
}
|
||||
drawMultiChart(canvas, lines, {
|
||||
maxPoints: maxPoints(),
|
||||
stacked: card.stacked,
|
||||
showYAxis: true,
|
||||
hoverIndex: state.hoverIndex,
|
||||
})
|
||||
if (legend) {
|
||||
legend.innerHTML = ''
|
||||
lines.forEach((line, idx) => {
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.className = 'dim-chip' + (line.hidden ? ' off' : '')
|
||||
btn.style.setProperty('--dim-color', line.color)
|
||||
const last = line.values[line.values.length - 1]
|
||||
btn.textContent = `${line.label}${last != null ? ` ${formatVal(last)}` : ''}`
|
||||
btn.title = 'Toggle dimension'
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
toggleDim(id, line.label)
|
||||
})
|
||||
legend.appendChild(btn)
|
||||
void idx
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDim(chartId, dim) {
|
||||
let set = state.hiddenDims.get(chartId)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
state.hiddenDims.set(chartId, set)
|
||||
}
|
||||
if (set.has(dim)) set.delete(dim)
|
||||
else set.add(dim)
|
||||
paintCard(chartId)
|
||||
}
|
||||
|
||||
function updateHoverReadout() {
|
||||
if (!opts.els.hoverReadout) return
|
||||
if (state.hoverIndex == null) {
|
||||
opts.els.hoverReadout.textContent = ''
|
||||
return
|
||||
}
|
||||
opts.els.hoverReadout.textContent = `cursor · sample ${state.hoverIndex + 1}`
|
||||
}
|
||||
|
||||
function bindCanvasHover(canvas) {
|
||||
canvas.addEventListener('mousemove', (ev) => {
|
||||
const idx = hoverIndexFromEvent(canvas, ev.clientX, {
|
||||
maxPoints: maxPoints(),
|
||||
showYAxis: true,
|
||||
seriesLen: maxPoints(),
|
||||
})
|
||||
state.hoverIndex = idx
|
||||
updateHoverReadout()
|
||||
for (const id of state.visible) paintCard(id)
|
||||
})
|
||||
canvas.addEventListener('mouseleave', () => {
|
||||
state.hoverIndex = null
|
||||
updateHoverReadout()
|
||||
for (const id of state.visible) paintCard(id)
|
||||
})
|
||||
}
|
||||
|
||||
function render() {
|
||||
const catalog = opts.getCatalog() || {}
|
||||
const { sections } = groupCatalog(catalog, state.filter)
|
||||
renderToc(sections)
|
||||
renderWall(sections)
|
||||
state.built = true
|
||||
if (opts.els.meta) {
|
||||
const total = Object.keys(catalog).length
|
||||
const shown = sections.reduce((n, s) => n + s.count, 0)
|
||||
opts.els.meta.textContent = `${shown} / ${total} charts · ${state.afterSeconds}s window`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof groupCatalog>['sections']} sections
|
||||
*/
|
||||
function renderToc(sections) {
|
||||
const toc = opts.els.toc
|
||||
if (!toc) return
|
||||
toc.innerHTML = ''
|
||||
if (!sections.length) {
|
||||
const empty = document.createElement('p')
|
||||
empty.className = 'muted'
|
||||
empty.textContent = 'No charts — connect an agent'
|
||||
toc.appendChild(empty)
|
||||
return
|
||||
}
|
||||
for (const sec of sections) {
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.className = 'toc-item'
|
||||
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' })
|
||||
})
|
||||
toc.appendChild(btn)
|
||||
for (const g of sec.groups.slice(0, 12)) {
|
||||
if (g.family === '_default') continue
|
||||
const sub = document.createElement('button')
|
||||
sub.type = 'button'
|
||||
sub.className = 'toc-sub'
|
||||
sub.textContent = g.family
|
||||
sub.title = g.family
|
||||
sub.addEventListener('click', () => {
|
||||
const section = opts.els.wall?.querySelector(`[data-section="${sec.id}"]`)
|
||||
const el = section
|
||||
? [...section.querySelectorAll('[data-family]')].find(
|
||||
(n) => n.getAttribute('data-family') === g.family
|
||||
)
|
||||
: null
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
})
|
||||
toc.appendChild(sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof groupCatalog>['sections']} sections
|
||||
*/
|
||||
function renderWall(sections) {
|
||||
const wall = opts.els.wall
|
||||
if (!wall) return
|
||||
if (state.observer) {
|
||||
state.observer.disconnect()
|
||||
state.observer = null
|
||||
}
|
||||
wall.innerHTML = ''
|
||||
state.visible.clear()
|
||||
state.cardEls.clear()
|
||||
|
||||
if (!sections.length) {
|
||||
const empty = document.createElement('div')
|
||||
empty.className = 'metrics-empty muted'
|
||||
empty.textContent = 'Connect an agent to load the metrics catalog'
|
||||
wall.appendChild(empty)
|
||||
return
|
||||
}
|
||||
|
||||
state.observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
const id = entry.target.getAttribute('data-chart-id')
|
||||
if (!id) continue
|
||||
if (entry.isIntersecting) {
|
||||
state.visible.add(id)
|
||||
const card = state.cards.get(id)
|
||||
if (!card || card.status === 'idle' || card.status === 'error') {
|
||||
fetchChart(id).catch(() => {})
|
||||
} else {
|
||||
paintCard(id)
|
||||
}
|
||||
} else {
|
||||
state.visible.delete(id)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ root: wall, rootMargin: '240px 0px', threshold: 0.05 }
|
||||
)
|
||||
|
||||
for (const sec of sections) {
|
||||
const sectionEl = document.createElement('section')
|
||||
sectionEl.className = 'metrics-section'
|
||||
sectionEl.dataset.section = sec.id
|
||||
if (state.collapsed.has(sec.id)) sectionEl.classList.add('collapsed')
|
||||
|
||||
const head = document.createElement('header')
|
||||
head.className = 'metrics-section-head'
|
||||
const toggle = document.createElement('button')
|
||||
toggle.type = 'button'
|
||||
toggle.className = 'section-toggle'
|
||||
toggle.textContent = state.collapsed.has(sec.id) ? '▸' : '▾'
|
||||
toggle.addEventListener('click', () => {
|
||||
if (state.collapsed.has(sec.id)) state.collapsed.delete(sec.id)
|
||||
else state.collapsed.add(sec.id)
|
||||
render()
|
||||
})
|
||||
const title = document.createElement('h2')
|
||||
title.textContent = sec.title
|
||||
const count = document.createElement('span')
|
||||
count.className = 'muted'
|
||||
count.textContent = `${sec.count}`
|
||||
head.append(toggle, title, count)
|
||||
sectionEl.appendChild(head)
|
||||
|
||||
const body = document.createElement('div')
|
||||
body.className = 'metrics-section-body'
|
||||
for (const g of sec.groups) {
|
||||
const fam = document.createElement('div')
|
||||
fam.className = 'metrics-family'
|
||||
fam.dataset.family = g.family
|
||||
if (g.family !== '_default') {
|
||||
const fh = document.createElement('h3')
|
||||
fh.className = 'metrics-family-title'
|
||||
fh.textContent = g.family
|
||||
fam.appendChild(fh)
|
||||
}
|
||||
const grid = document.createElement('div')
|
||||
grid.className = 'metrics-grid'
|
||||
for (const { id, meta } of g.charts) {
|
||||
ensureCard(id, meta)
|
||||
const article = document.createElement('article')
|
||||
article.className = 'metric-card'
|
||||
article.dataset.chartId = id
|
||||
article.innerHTML = `
|
||||
<header class="metric-card-head">
|
||||
<div>
|
||||
<h4 class="metric-card-title">${escapeHtml(meta.title || id)}</h4>
|
||||
<p class="metric-card-sub muted">${escapeHtml(id)}${meta.units ? ` · ${escapeHtml(meta.units)}` : ''}</p>
|
||||
</div>
|
||||
<span class="metric-card-status muted"></span>
|
||||
</header>
|
||||
<canvas height="160"></canvas>
|
||||
<div class="metric-card-legend"></div>
|
||||
`
|
||||
const canvas = article.querySelector('canvas')
|
||||
if (canvas) bindCanvasHover(canvas)
|
||||
article.addEventListener('dblclick', () => opts.onSelectChart?.(id))
|
||||
state.cardEls.set(id, article)
|
||||
grid.appendChild(article)
|
||||
state.observer.observe(article)
|
||||
}
|
||||
fam.appendChild(grid)
|
||||
body.appendChild(fam)
|
||||
}
|
||||
sectionEl.appendChild(body)
|
||||
wall.appendChild(sectionEl)
|
||||
}
|
||||
}
|
||||
|
||||
function bindChrome() {
|
||||
opts.els.search?.addEventListener('input', () => {
|
||||
state.filter = opts.els.search?.value || ''
|
||||
render()
|
||||
})
|
||||
opts.els.playBtn?.addEventListener('click', () => setPlaying(!state.playing))
|
||||
opts.els.presets?.querySelectorAll('[data-preset]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => setPreset(btn.getAttribute('data-preset') || '5m'))
|
||||
})
|
||||
setPlaying(true)
|
||||
setPreset(state.presetId)
|
||||
}
|
||||
|
||||
function setCatalog(_catalog) {
|
||||
render()
|
||||
}
|
||||
|
||||
function redrawVisible() {
|
||||
for (const id of state.visible) paintCard(id)
|
||||
}
|
||||
|
||||
/** @param {string} id */
|
||||
function scrollToChart(id) {
|
||||
const el = state.cardEls.get(id)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
el.classList.add('flash')
|
||||
setTimeout(() => el.classList.remove('flash'), 1200)
|
||||
return
|
||||
}
|
||||
// Chart may be filtered out — clear filter and re-render
|
||||
state.filter = ''
|
||||
if (opts.els.search) opts.els.search.value = ''
|
||||
render()
|
||||
requestAnimationFrame(() => {
|
||||
const again = state.cardEls.get(id)
|
||||
again?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
})
|
||||
}
|
||||
|
||||
bindChrome()
|
||||
|
||||
return {
|
||||
render,
|
||||
setCatalog,
|
||||
onSamples,
|
||||
setPlaying,
|
||||
setPreset,
|
||||
redrawVisible,
|
||||
scrollToChart,
|
||||
getState: () => state,
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} s */
|
||||
function escapeHtml(s) {
|
||||
return String(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
/** @param {number} v */
|
||||
function formatVal(v) {
|
||||
const a = Math.abs(v)
|
||||
if (a >= 1e6) return (v / 1e6).toFixed(2) + 'M'
|
||||
if (a >= 1e3) return (v / 1e3).toFixed(2) + 'k'
|
||||
if (a >= 10) return v.toFixed(1)
|
||||
return v.toFixed(2)
|
||||
}
|
||||
+260
-2
@@ -656,7 +656,254 @@ body.is-offline .offline-banner:not(.hidden) {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* ─── Charts browser ─── */
|
||||
/* ─── Master metrics wall (Charts tab) ─── */
|
||||
.metrics-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 20px;
|
||||
}
|
||||
|
||||
.metrics-timebar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
}
|
||||
|
||||
.metrics-presets {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
padding: 2px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-elevated, rgba(255, 255, 255, 0.04));
|
||||
}
|
||||
|
||||
.metrics-presets .ghost.active {
|
||||
background: rgba(52, 211, 153, 0.16);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.metrics-meta,
|
||||
.metrics-hover {
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.metrics-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
gap: var(--space);
|
||||
min-height: 480px;
|
||||
height: calc(100vh - var(--titlebar-h) - 168px);
|
||||
}
|
||||
|
||||
.metrics-toc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.metrics-toc-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.toc-item,
|
||||
.toc-sub {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.toc-item:hover,
|
||||
.toc-sub:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toc-item {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toc-sub {
|
||||
padding-left: 18px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.toc-count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.metrics-wall {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.metrics-empty {
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metrics-section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.metrics-section.collapsed .metrics-section-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.metrics-section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding: 8px 0;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
var(--bg-primary) 70%,
|
||||
transparent
|
||||
);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.metrics-section-head h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.section-toggle {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.metrics-family {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.metrics-family-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 14px 10px;
|
||||
border-radius: 14px;
|
||||
background: var(--bg-card, rgba(255, 255, 255, 0.03));
|
||||
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.06));
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.metric-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.metric-card-title {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.metric-card-sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.metric-card-status {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.metric-card canvas {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.metric-card-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.dim-chip {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
background: color-mix(in srgb, var(--dim-color, #94a3b8) 18%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--dim-color, #94a3b8) 45%, transparent);
|
||||
}
|
||||
|
||||
.dim-chip.off {
|
||||
opacity: 0.35;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.metric-card.flash {
|
||||
box-shadow: 0 0 0 1px rgba(52, 211, 153, 0.55), 0 0 24px rgba(52, 211, 153, 0.18);
|
||||
}
|
||||
|
||||
.metrics-detail {
|
||||
margin-top: var(--space);
|
||||
}
|
||||
|
||||
.charts-browser {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
@@ -1040,9 +1287,20 @@ code {
|
||||
}
|
||||
.charts-grid,
|
||||
.fleet-layout,
|
||||
.charts-browser {
|
||||
.charts-browser,
|
||||
.metrics-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.metrics-shell {
|
||||
height: auto;
|
||||
max-height: none;
|
||||
}
|
||||
.metrics-toc {
|
||||
max-height: 220px;
|
||||
}
|
||||
.metrics-wall {
|
||||
max-height: 70vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
|
||||
Reference in New Issue
Block a user