Update
CI / test (push) Successful in 58s
Release rolling / release (push) Successful in 7m14s

This commit is contained in:
Raven Scott
2026-07-19 15:27:00 -04:00
parent 926a89878b
commit 2923678354
6 changed files with 1047 additions and 105 deletions
+187 -44
View File
@@ -25,6 +25,7 @@ import {
import { drawChart, CHART_PALETTE, pushRing } from './ui/charts.js' import { drawChart, CHART_PALETTE, pushRing } from './ui/charts.js'
import { defaultModeFromMeta } from './shared/chart-types.js' import { defaultModeFromMeta } from './shared/chart-types.js'
import { createMetricsDashboard } from './ui/dashboard.js' import { createMetricsDashboard } from './ui/dashboard.js'
import { getChartFocus } from './ui/chart-focus.js'
import { import {
buildFleetRoster, buildFleetRoster,
summarizeFleet, summarizeFleet,
@@ -354,6 +355,172 @@ function paint(canvasId, lines, opts = {}) {
drawChart($(canvasId), lines, { ...opts, maxPoints: seriesMax() }) drawChart($(canvasId), lines, { ...opts, maxPoints: seriesMax() })
} }
const overviewFocus = getChartFocus()
/** @type {Array<{ id: string, title: string, subtitle: string, canvasId: string, lines: () => any[], opts: () => object }>} */
const OVERVIEW_FOCUS = [
{
id: 'system.cpu',
title: 'CPU',
subtitle: 'system.cpu',
canvasId: 'chart-cpu',
lines: () => overviewCpuLines().lines,
opts: () => ({ ...anomalyOptsFor('system.cpu'), ...overviewCpuLines().opts }),
},
{
id: 'system.ram',
title: 'Memory',
subtitle: 'system.ram',
canvasId: 'chart-ram',
lines: () => [{ values: series.ram, color: CHART_PALETTE.ram }],
opts: () => ({
...anomalyOptsFor(anomalyByChart.has('mem.available') ? 'mem.available' : 'system.ram'),
mode: 'area',
}),
},
{
id: 'system.net',
title: 'Network',
subtitle: 'system.net',
canvasId: 'chart-net',
lines: () =>
series.netTx.length >= 2
? [
{ values: series.net, color: CHART_PALETTE.net, label: 'rx' },
{ values: series.netTx, color: CHART_PALETTE.disk, label: 'tx' },
]
: [{ values: series.net, color: CHART_PALETTE.net, label: 'rx' }],
opts: () => ({ ...anomalyOptsFor('system.net'), mode: 'area' }),
},
{
id: 'system.io',
title: 'Disk I/O',
subtitle: 'system.io',
canvasId: 'chart-io',
lines: () =>
series.ioWrite.length >= 2
? [
{ values: series.io, color: CHART_PALETTE.disk, label: 'read' },
{ values: series.ioWrite, color: CHART_PALETTE.cpuIowait, label: 'write' },
]
: [{ values: series.io, color: CHART_PALETTE.disk, label: 'io' }],
opts: () => ({ ...anomalyOptsFor('system.io'), mode: 'area' }),
},
{
id: 'system.load',
title: 'Load',
subtitle: 'system.load',
canvasId: 'chart-load',
lines: () => [{ values: series.load, color: CHART_PALETTE.load }],
opts: () => ({ ...anomalyOptsFor('system.load'), mode: 'line' }),
},
{
id: 'explore',
title: 'Spotlight',
subtitle: () => exploreChartId,
canvasId: 'chart-explore',
lines: () => [{ values: series.explore, color: CHART_PALETTE.explore }],
opts: () => ({
...anomalyOptsFor(exploreChartId),
mode: defaultModeFromMeta(chartCatalog[exploreChartId] || { chartType: 'line' }),
}),
},
]
function overviewCpuLines() {
if (els.compareToggle?.checked && peerCpu.size > 0) {
const lines = [...peerCpu.entries()].slice(0, 5).map(([id, values], i) => ({
values,
color: CHART_PALETTE.compare[i % CHART_PALETTE.compare.length],
label: id.slice(0, 8),
fill: false,
}))
return { lines, opts: { mode: 'line' } }
}
const lines = [
{ values: series.cpuUser.length ? series.cpuUser : series.cpu, color: CHART_PALETTE.cpuUser, label: 'user', fill: false },
{ values: series.cpuSystem, color: CHART_PALETTE.cpuSystem, label: 'system', fill: false },
{ values: series.cpuIowait, color: CHART_PALETTE.cpuIowait, label: 'iowait', fill: false },
].filter((l) => l.values.length >= 2)
if (!lines.length) {
return {
lines: [{ values: series.cpu, color: CHART_PALETTE.cpuUser, label: 'used' }],
opts: { mode: 'area' },
}
}
return { lines, opts: { mode: 'stacked' } }
}
function openOverviewFocus(focusId) {
const idx = OVERVIEW_FOCUS.findIndex((p) => p.id === focusId)
if (idx < 0) return
const openAt = (i) => {
const panel = OVERVIEW_FOCUS[(i + OVERVIEW_FOCUS.length) % OVERVIEW_FOCUS.length]
const sub =
typeof panel.subtitle === 'function' ? panel.subtitle() : panel.subtitle
overviewFocus.open({
id: panel.id,
title: panel.title,
subtitle: sub,
paint: (canvas) => {
drawChart(canvas, panel.lines(), { ...panel.opts(), maxPoints: seriesMax() })
},
onPrev: () => openAt(i - 1),
onNext: () => openAt(i + 1),
actions: [
{
id: 'charts',
label: 'Open in Charts',
title: 'Jump to Charts wall',
onClick: () => {
overviewFocus.close()
const chartId = panel.id === 'explore' ? exploreChartId : panel.id
showView('charts')
metricsDashboard.scrollToChart?.(chartId)
metricsDashboard.openFocus?.(chartId)
},
},
],
})
overviewFocus.refresh()
}
openAt(idx)
}
function bindOverviewFocus() {
const panels = [
[els.panelCpu, 'system.cpu'],
[els.panelRam, 'system.ram'],
[els.panelNet, 'system.net'],
[els.panelIo, 'system.io'],
[els.panelLoad, 'system.load'],
[els.panelExplore, 'explore'],
]
for (const [panel, id] of panels) {
if (!panel || panel.dataset.focusBound) continue
panel.dataset.focusBound = '1'
panel.classList.add('chart-panel--focusable')
if (!panel.querySelector('.chart-focus-btn')) {
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'btn btn-ghost chart-focus-btn'
btn.title = 'Fullscreen'
btn.setAttribute('aria-label', 'Fullscreen')
btn.textContent = '⛶'
const header = panel.querySelector('header')
header?.appendChild(btn)
btn.addEventListener('click', (ev) => {
ev.stopPropagation()
openOverviewFocus(id)
})
}
panel.addEventListener('dblclick', (ev) => {
if (ev.target instanceof HTMLElement && ev.target.closest('select,button,a,label')) return
openOverviewFocus(id)
})
}
}
let overviewPaintRaf = 0 let overviewPaintRaf = 0
/** Coalesce Overview redraws into one frame to avoid spark blink. */ /** Coalesce Overview redraws into one frame to avoid spark blink. */
function scheduleRedrawAll() { function scheduleRedrawAll() {
@@ -458,44 +625,20 @@ function anomalyOptsFor(chart) {
} }
function redrawCpu() { function redrawCpu() {
const opts = anomalyOptsFor('system.cpu') const { lines, opts } = overviewCpuLines()
paint('chart-cpu', lines, { ...anomalyOptsFor('system.cpu'), ...opts })
if (els.compareToggle?.checked && peerCpu.size > 0) { if (els.compareToggle?.checked && peerCpu.size > 0) {
const lines = [...peerCpu.entries()].slice(0, 5).map(([id, values], i) => ({
values,
color: CHART_PALETTE.compare[i % CHART_PALETTE.compare.length],
label: id.slice(0, 8),
fill: false,
}))
paint('chart-cpu', lines, opts)
if (els.chartCpuLabel) els.chartCpuLabel.textContent = `compare · ${lines.length} peers` if (els.chartCpuLabel) els.chartCpuLabel.textContent = `compare · ${lines.length} peers`
if (els.legendCpu) { } else if (els.chartCpuLabel) {
els.legendCpu.innerHTML = lines els.chartCpuLabel.textContent = 'system.cpu'
.map( }
(l) => if (els.legendCpu) {
`<span style="--swatch:${l.color}">${escapeHtml(l.label || '')}</span>` els.legendCpu.innerHTML = (lines.length ? lines : [{ color: CHART_PALETTE.cpuUser, label: 'used' }])
) .map(
.join('') (l) =>
} `<span style="--swatch:${l.color}">${escapeHtml(l.label || '')}</span>`
} else { )
const lines = [ .join('')
{ values: series.cpuUser.length ? series.cpuUser : series.cpu, color: CHART_PALETTE.cpuUser, label: 'user', fill: false },
{ values: series.cpuSystem, color: CHART_PALETTE.cpuSystem, label: 'system', fill: false },
{ 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,
mode: 'area',
})
} else {
paint('chart-cpu', lines, { ...opts, mode: 'stacked' })
}
if (els.chartCpuLabel) els.chartCpuLabel.textContent = 'system.cpu'
if (els.legendCpu) {
els.legendCpu.innerHTML = (lines.length ? lines : [{ color: CHART_PALETTE.cpuUser, label: 'used' }])
.map((l) => `<span style="--swatch:${l.color}">${l.label}</span>`)
.join('')
}
} }
} }
@@ -511,10 +654,7 @@ function redrawAll() {
) )
paint( paint(
'chart-net', 'chart-net',
[ series.netTx.length >= 2
{ 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 }, { values: series.net, color: CHART_PALETTE.net },
{ values: series.netTx, color: CHART_PALETTE.disk }, { values: series.netTx, color: CHART_PALETTE.disk },
@@ -524,10 +664,7 @@ function redrawAll() {
) )
paint( paint(
'chart-io', 'chart-io',
[ series.ioWrite.length >= 2
{ 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 }, { values: series.io, color: CHART_PALETTE.disk },
{ values: series.ioWrite, color: CHART_PALETTE.cpuIowait }, { values: series.ioWrite, color: CHART_PALETTE.cpuIowait },
@@ -548,6 +685,11 @@ function redrawAll() {
mode: defaultModeFromMeta(chartCatalog[exploreChartId] || { chartType: 'line' }), mode: defaultModeFromMeta(chartCatalog[exploreChartId] || { chartType: 'line' }),
} }
) )
// Live-update Overview focus only (Charts wall owns its own paint callback)
if (overviewFocus.isOpen()) {
const id = overviewFocus.currentId()
if (OVERVIEW_FOCUS.some((p) => p.id === id)) overviewFocus.refresh()
}
} }
function updateActivePeerChip() { function updateActivePeerChip() {
@@ -1470,6 +1612,7 @@ async function restorePeersOnBoot() {
setOnline(false) setOnline(false)
syncSettingsUi() syncSettingsUi()
bindOverviewFocus()
showView(loadBookmarks().length ? 'fleet' : 'connect') showView(loadBookmarks().length ? 'fleet' : 'connect')
renderBookmarks() renderBookmarks()
renderPeers() renderPeers()
+2
View File
@@ -98,6 +98,7 @@ Do not name third-party products in code, commits, or user-facing copy.
- [x] Related ranking boosted by alert weights when no MC window - [x] Related ranking boosted by alert weights when no MC window
- [x] Drag-reorder pinned charts - [x] Drag-reorder pinned charts
- [x] Keyboard shortcuts on Charts (`Space` play, `15` presets, `/` search, `f` force, `b` board, `c` correlate, `r` reset, `Esc` clear) - [x] Keyboard shortcuts on Charts (`Space` play, `15` presets, `/` search, `f` force, `b` board, `c` correlate, `r` reset, `Esc` clear)
- [x] Fullscreen chart focus (Charts wall + Overview) — double-click / ⛶, `Esc` / `←` `→`
--- ---
@@ -125,6 +126,7 @@ Do not name third-party products in code, commits, or user-facing copy.
| `shared/related-metrics.js` | Related-chart ranking (taxonomy + Pearson) | | `shared/related-metrics.js` | Related-chart ranking (taxonomy + Pearson) |
| `server/services/weights.js` | Metric Correlations scoring (ks2 / volume / AR / value) | | `server/services/weights.js` | Metric Correlations scoring (ks2 / volume / AR / value) |
| `ui/dashboard.js` | Metrics wall controller + Correlate mode | | `ui/dashboard.js` | Metrics wall controller + Correlate mode |
| `ui/chart-focus.js` | Fullscreen chart overlay (shared) |
| `ui/charts.js` | Canvas paint + hover helpers | | `ui/charts.js` | Canvas paint + hover helpers |
| `ui/data-manager.js` | Settings → Data (retention / prune / usage) | | `ui/data-manager.js` | Settings → Data (retention / prune / usage) |
| `ui/logs.js` | System Log tab (journal default) | | `ui/logs.js` | System Log tab (journal default) |
+1 -1
View File
@@ -182,7 +182,7 @@
<div> <div>
<p class="dash-kicker">Metrics</p> <p class="dash-kicker">Metrics</p>
<h1 class="dash-title">Charts</h1> <h1 class="dash-title">Charts</h1>
<p class="page-subtitle">Scroll freely · <kbd></kbd>/<kbd></kbd>+scroll zooms · <kbd>Space</kbd> · <kbd>/</kbd> · <kbd>15</kbd></p> <p class="page-subtitle">Double-click or <span class="page-hint-ico"></span> for fullscreen · <kbd>Space</kbd> · <kbd>/</kbd> · <kbd>15</kbd> · <kbd>Esc</kbd></p>
</div> </div>
</header> </header>
<div class="metrics-toolbar" id="metrics-timebar"> <div class="metrics-toolbar" id="metrics-timebar">
+271
View File
@@ -0,0 +1,271 @@
/**
* Fullscreen chart focus overlay — shared by Charts wall + Overview sparks.
*/
/**
* @typedef {{
* id?: string,
* title?: string,
* subtitle?: string,
* units?: string,
* status?: string,
* paint: (canvas: HTMLCanvasElement) => void,
* onClose?: () => void,
* onPrev?: (() => void)|null,
* onNext?: (() => void)|null,
* actions?: Array<{ id: string, label: string, title?: string, active?: boolean, onClick: () => void }>,
* }} FocusOpts
*/
/**
* @returns {{
* open: (opts: FocusOpts) => void,
* close: () => void,
* isOpen: () => boolean,
* currentId: () => string,
* refresh: () => void,
* updateChrome: (patch: Partial<FocusOpts>) => void,
* setLegend: (html: string) => void,
* el: HTMLElement,
* }}
*/
export function createChartFocus() {
const root = document.createElement('div')
root.id = 'chart-focus'
root.className = 'chart-focus hidden'
root.tabIndex = -1
root.setAttribute('role', 'dialog')
root.setAttribute('aria-modal', 'true')
root.setAttribute('aria-labelledby', 'chart-focus-title')
root.innerHTML = `
<div class="chart-focus-backdrop" data-focus-close="1"></div>
<div class="chart-focus-stage">
<header class="chart-focus-head">
<div class="chart-focus-titles">
<p class="chart-focus-kicker muted" id="chart-focus-sub"></p>
<h2 id="chart-focus-title">Chart</h2>
<p class="chart-focus-meta muted">
<span id="chart-focus-units"></span>
<span id="chart-focus-status"></span>
</p>
</div>
<div class="chart-focus-actions" id="chart-focus-actions"></div>
<button type="button" class="btn btn-ghost chart-focus-close" data-focus-close="1" title="Close (Esc)" aria-label="Close fullscreen">✕</button>
</header>
<div class="chart-focus-body">
<button type="button" class="chart-focus-nav chart-focus-prev" id="chart-focus-prev" title="Previous chart (←)" aria-label="Previous chart"></button>
<div class="chart-focus-canvas-wrap">
<canvas id="chart-focus-canvas"></canvas>
</div>
<button type="button" class="chart-focus-nav chart-focus-next" id="chart-focus-next" title="Next chart (→)" aria-label="Next chart"></button>
</div>
<footer class="chart-focus-foot">
<div id="chart-focus-legend" class="chart-focus-legend"></div>
<p class="chart-focus-hint muted"><kbd>Esc</kbd> close · <kbd>←</kbd><kbd>→</kbd> navigate · <kbd>Space</kbd> pause · <kbd>1</kbd><kbd>5</kbd> window</p>
</footer>
</div>
`
document.body.appendChild(root)
const titleEl = /** @type {HTMLElement} */ (root.querySelector('#chart-focus-title'))
const subEl = /** @type {HTMLElement} */ (root.querySelector('#chart-focus-sub'))
const unitsEl = /** @type {HTMLElement} */ (root.querySelector('#chart-focus-units'))
const statusEl = /** @type {HTMLElement} */ (root.querySelector('#chart-focus-status'))
const actionsEl = /** @type {HTMLElement} */ (root.querySelector('#chart-focus-actions'))
const legendEl = /** @type {HTMLElement} */ (root.querySelector('#chart-focus-legend'))
const canvas = /** @type {HTMLCanvasElement} */ (root.querySelector('#chart-focus-canvas'))
const prevBtn = /** @type {HTMLButtonElement} */ (root.querySelector('#chart-focus-prev'))
const nextBtn = /** @type {HTMLButtonElement} */ (root.querySelector('#chart-focus-next'))
/** @type {FocusOpts|null} */
let current = null
let resizeObs = /** @type {ResizeObserver|null} */ (null)
let openRaf = 0
function paintNow() {
if (!current?.paint || !canvas) return
try {
current.paint(canvas)
} catch {
// ignore paint errors in focus
}
}
function schedulePaint() {
if (openRaf) return
openRaf = requestAnimationFrame(() => {
openRaf = 0
paintNow()
})
}
function syncNav() {
const hasPrev = Boolean(current?.onPrev)
const hasNext = Boolean(current?.onNext)
prevBtn.classList.toggle('hidden', !hasPrev)
nextBtn.classList.toggle('hidden', !hasNext)
prevBtn.disabled = !hasPrev
nextBtn.disabled = !hasNext
}
function syncActions() {
const actions = current?.actions || []
actionsEl.innerHTML = ''
for (const a of actions) {
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'btn btn-ghost' + (a.active ? ' is-active' : '')
btn.dataset.focusAction = a.id
btn.textContent = a.label
if (a.title) btn.title = a.title
btn.addEventListener('click', (ev) => {
ev.stopPropagation()
a.onClick()
})
actionsEl.appendChild(btn)
}
}
/**
* @param {Partial<FocusOpts>} patch
*/
function updateChrome(patch = {}) {
if (!current) return
current = { ...current, ...patch }
titleEl.textContent = current.title || current.id || 'Chart'
subEl.textContent = current.subtitle || current.id || ''
unitsEl.textContent = current.units ? String(current.units) : ''
statusEl.textContent = current.status ? String(current.status) : ''
syncNav()
if (patch.actions) syncActions()
}
/**
* Optional legend HTML from the host (dimension chips).
* @param {string} html
*/
function setLegend(html) {
legendEl.innerHTML = html || ''
}
/**
* @param {FocusOpts} opts
*/
function open(opts) {
current = { ...opts }
updateChrome(opts)
syncActions()
root.classList.remove('hidden')
requestAnimationFrame(() => root.classList.add('is-open'))
document.body.classList.add('chart-focus-open')
schedulePaint()
if (!resizeObs && typeof ResizeObserver !== 'undefined') {
resizeObs = new ResizeObserver(() => schedulePaint())
const wrap = root.querySelector('.chart-focus-canvas-wrap')
if (wrap) resizeObs.observe(wrap)
}
try {
root.focus({ preventScroll: true })
} catch {
// ignore
}
}
function close() {
if (!current) return
const onClose = current.onClose
current = null
root.classList.remove('is-open')
const hide = () => {
root.classList.add('hidden')
legendEl.innerHTML = ''
document.body.classList.remove('chart-focus-open')
}
// Allow exit animation; fall back if reduced motion
let done = false
const finish = () => {
if (done) return
done = true
hide()
}
root.addEventListener('transitionend', finish, { once: true })
setTimeout(finish, 220)
onClose?.()
}
function isOpen() {
return Boolean(current) && !root.classList.contains('hidden')
}
function currentId() {
return current?.id || ''
}
function refresh() {
if (!isOpen()) return
schedulePaint()
}
root.addEventListener('click', (ev) => {
const t = /** @type {HTMLElement} */ (ev.target)
if (t?.closest?.('[data-focus-close]')) {
ev.preventDefault()
close()
}
})
prevBtn.addEventListener('click', (ev) => {
ev.stopPropagation()
current?.onPrev?.()
})
nextBtn.addEventListener('click', (ev) => {
ev.stopPropagation()
current?.onNext?.()
})
window.addEventListener('keydown', (ev) => {
if (!isOpen()) return
const tag = (ev.target && /** @type {HTMLElement} */ (ev.target).tagName) || ''
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
if (ev.key === 'Escape') {
ev.preventDefault()
ev.stopPropagation()
close()
} else if (ev.key === 'ArrowLeft') {
if (current?.onPrev) {
ev.preventDefault()
current.onPrev()
}
} else if (ev.key === 'ArrowRight') {
if (current?.onNext) {
ev.preventDefault()
current.onNext()
}
}
})
// Keep canvas sharp on DPR / window resize
window.addEventListener('resize', () => {
if (isOpen()) schedulePaint()
})
return {
open,
close,
isOpen,
currentId,
refresh,
updateChrome,
setLegend,
el: root,
}
}
/** @type {ReturnType<typeof createChartFocus>|null} */
let sharedFocus = null
/** App-wide singleton so Overview + Charts share one overlay. */
export function getChartFocus() {
if (!sharedFocus) sharedFocus = createChartFocus()
return sharedFocus
}
+248 -57
View File
@@ -12,6 +12,7 @@ import {
} from '../shared/chart-types.js' } from '../shared/chart-types.js'
import { drawChart, hoverIndexFromEvent, padLeftFor, pushDim, seriesColor } from './charts.js' import { drawChart, hoverIndexFromEvent, padLeftFor, pushDim, seriesColor } from './charts.js'
import { chartOptionLabel, metricCardSubtitle } from '../shared/container-names.js' import { chartOptionLabel, metricCardSubtitle } from '../shared/container-names.js'
import { getChartFocus } from './chart-focus.js'
const GROUPS = ['average', 'min', 'max', 'sum'] const GROUPS = ['average', 'min', 'max', 'sum']
@@ -90,6 +91,7 @@ function smoothScrollIntoView(el, block = 'start') {
*/ */
export function createMetricsDashboard(opts) { export function createMetricsDashboard(opts) {
const prefs = () => opts.getPrefs?.() || {} const prefs = () => opts.getPrefs?.() || {}
const focus = getChartFocus()
const state = { const state = {
filter: '', filter: '',
@@ -532,6 +534,208 @@ export function createMetricsDashboard(opts) {
return { dims, hidden } return { dims, hidden }
} }
/**
* Build drawable series + draw opts for a chart card.
* @param {string} id
*/
function buildPaintModel(id) {
const card = state.cards.get(id)
if (!card) return null
const { dims, hidden } = sortedDims(card, id)
const mode = normalizeChartMode(card.mode || defaultModeFromMeta(card.meta))
card.mode = mode
/** @type {Array<{ values: number[], color: string, label: string, hidden: boolean, fill?: boolean }>} */
const lines = []
let i = 0
for (const dim of dims) {
const values = card.dims.get(dim) || []
lines.push({
values,
color: seriesColor(i),
label: dim,
hidden: hidden.has(dim),
fill: mode === 'area' ? true : mode === 'line' ? false : undefined,
})
i++
}
const anomaly = opts.getAnomaly?.(id) || null
const emptyMsg =
card.status === 'loading'
? card.emptyReason || 'Loading…'
: card.status === 'error'
? card.emptyReason || 'Error'
: card.status === 'empty'
? card.emptyReason || 'No data'
: 'No data'
const units = card.meta?.units || ''
const showY = mode !== 'pie'
return {
card,
dims,
hidden,
mode,
lines,
anomaly,
units,
emptyMsg,
drawOpts: {
mode,
maxPoints: maxPoints(),
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: false,
units,
windowSeconds: mode === 'pie' ? null : state.afterSeconds,
endOffset: state.endOffset,
},
}
}
/** Ordered chart ids currently on the wall (for focus prev/next). */
function wallChartIds() {
const ids = []
for (const el of opts.els.wall?.querySelectorAll('[data-chart-id]') || []) {
const id = el.getAttribute('data-chart-id')
if (id) ids.push(id)
}
return ids.length ? ids : [...state.cardEls.keys()]
}
/**
* @param {string} id
* @param {HTMLCanvasElement} canvas
* @param {ReturnType<typeof buildPaintModel>} model
*/
function paintCanvas(id, canvas, model) {
if (!model || !canvas) return
drawChart(canvas, model.lines, model.drawOpts)
}
function focusLegendHtml(model) {
if (!model) return ''
const show = model.lines.slice(0, LEGEND_CHIP_CAP)
const extra = Math.max(0, model.lines.length - LEGEND_CHIP_CAP)
const unitSuffix = model.units ? ` ${model.units}` : ''
return (
show
.map((line) => {
const last = line.values[line.values.length - 1]
const text = `${line.label}${last != null ? ` ${formatVal(last)}${unitSuffix}` : ''}`
return `<span class="dim-chip${line.hidden ? ' off' : ''}" style="--dim-color:${line.color}">${escapeHtml(text)}</span>`
})
.join('') +
(extra ? `<span class="dim-chip-more muted">+${extra}</span>` : '')
)
}
function syncFocusChrome(id) {
if (!focus.isOpen() || focus.currentId() !== id) return
const model = buildPaintModel(id)
const catalog = opts.getCatalog() || {}
const meta = catalog[id] || state.cards.get(id)?.meta || {}
const status =
model?.card?.status === 'ok' && model.card.source && model.card.source !== 'memory'
? model.card.source
: model?.card?.status === 'ok'
? ''
: model?.card?.status || ''
focus.updateChrome({
id,
title: meta.title || id,
subtitle: metricCardSubtitle(id, meta),
units: meta.units || model?.units || '',
status,
actions: focusActionsFor(id),
onPrev: () => focusStep(-1),
onNext: () => focusStep(1),
})
focus.setLegend(focusLegendHtml(model))
}
function focusActionsFor(id) {
const card = state.cards.get(id)
const mode = card?.mode || 'line'
return [
{
id: 'pin',
label: state.pinned.has(id) ? '★ Pin' : '☆ Pin',
title: 'Pin to board',
active: state.pinned.has(id),
onClick: () => {
togglePin(id)
syncFocusChrome(id)
},
},
{
id: 'type',
label: CHART_MODE_LABEL[mode] || mode,
title: chartModeHint(mode),
onClick: () => {
cycleChartType(id)
syncFocusChrome(id)
focus.refresh()
},
},
{
id: 'related',
label: 'Related',
title: 'Find related charts',
onClick: () => {
focus.close()
showRelated(id)
},
},
]
}
function openFocus(id) {
if (!id) return
ensureCard(id, (opts.getCatalog() || {})[id] || {})
if (!state.cards.get(id)?.dims?.size) {
fetchChart(id).catch(() => {})
}
const catalog = opts.getCatalog() || {}
const meta = catalog[id] || {}
focus.open({
id,
title: meta.title || id,
subtitle: metricCardSubtitle(id, meta),
units: meta.units || '',
paint: (canvas) => {
const model = buildPaintModel(id)
paintCanvas(id, canvas, model)
},
onPrev: () => focusStep(-1),
onNext: () => focusStep(1),
actions: focusActionsFor(id),
onClose: () => {
const el = state.cardEls.get(id)
el?.classList.remove('is-focused')
},
})
for (const el of state.cardEls.values()) el.classList.remove('is-focused')
state.cardEls.get(id)?.classList.add('is-focused')
syncFocusChrome(id)
focus.refresh()
}
/** @param {number} delta */
function focusStep(delta) {
const ids = wallChartIds()
if (!ids.length) return
const cur = focus.currentId()
let idx = ids.indexOf(cur)
if (idx < 0) idx = 0
const next = ids[(idx + delta + ids.length) % ids.length]
openFocus(next)
const el = state.cardEls.get(next)
if (el && !state.visible.has(next)) smoothScrollIntoView(el, 'center')
}
function paintCard(id) { function paintCard(id) {
const article = state.cardEls.get(id) const article = state.cardEls.get(id)
if (!article) return if (!article) return
@@ -541,8 +745,14 @@ export function createMetricsDashboard(opts) {
const legend = article.querySelector('.metric-card-legend') const legend = article.querySelector('.metric-card-legend')
article.classList.toggle('pinned', state.pinned.has(id)) article.classList.toggle('pinned', state.pinned.has(id))
article.classList.toggle('related', state.relatedIds.has(id)) article.classList.toggle('related', state.relatedIds.has(id))
article.classList.toggle('is-focused', focus.isOpen() && focus.currentId() === id)
const pinBtn = article.querySelector('.metric-pin-btn') const pinBtn = article.querySelector('.metric-pin-btn')
if (pinBtn) pinBtn.textContent = state.pinned.has(id) ? '★' : '☆' if (pinBtn) pinBtn.textContent = state.pinned.has(id) ? '★' : '☆'
const typeBtn = article.querySelector('.metric-type-btn')
if (typeBtn && card) {
typeBtn.textContent = CHART_MODE_LABEL[card.mode] || card.mode
typeBtn.title = chartModeHint(card.mode)
}
// Never dim the card during SWR — opacity/filter changes look like a blink // Never dim the card during SWR — opacity/filter changes look like a blink
article.classList.remove('updating') article.classList.remove('updating')
if (statusEl) { if (statusEl) {
@@ -564,64 +774,25 @@ export function createMetricsDashboard(opts) {
weightChip.classList.add('hidden') weightChip.classList.add('hidden')
} }
} }
if (!card || !canvas) return const model = buildPaintModel(id)
if (!card || !canvas || !model) return
const height = cardHeightFor(id, card) const height = cardHeightFor(id, card)
const heightPx = `${height}px` const heightPx = `${height}px`
if (canvas.style.height !== heightPx) canvas.style.height = heightPx if (canvas.style.height !== heightPx) canvas.style.height = heightPx
updateHighlightBand(id) updateHighlightBand(id)
const { dims, hidden } = sortedDims(card, id) if (model.anomaly?.severity) article.dataset.severity = model.anomaly.severity
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) || []
lines.push({
values,
color: seriesColor(i),
label: dim,
hidden: hidden.has(dim),
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 else delete article.dataset.severity
article.dataset.chartMode = mode article.dataset.chartMode = model.mode
const emptyMsg = paintCanvas(id, canvas, model)
card.status === 'loading' updateCardTooltip(article, id, card, model.lines, model.units)
? card.emptyReason || 'Loading…'
: card.status === 'error'
? card.emptyReason || 'Error'
: card.status === 'empty'
? card.emptyReason || 'No data'
: 'No data'
const units = card.meta?.units || ''
const showY = mode !== 'pie'
drawChart(canvas, lines, {
mode,
maxPoints: maxPoints(),
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: false,
units,
windowSeconds: mode === 'pie' ? null : state.afterSeconds,
endOffset: state.endOffset,
})
updateCardTooltip(article, id, card, lines, units)
const statsEl = article.querySelector('.metric-card-stats') const statsEl = article.querySelector('.metric-card-stats')
if (statsEl && article.classList.contains('expanded')) { if (statsEl && article.classList.contains('expanded')) {
statsEl.innerHTML = renderStatsHtml(card, dims, hidden, units) statsEl.innerHTML = renderStatsHtml(card, model.dims, model.hidden, model.units)
} }
if (legend && !card.updating) { if (legend && !card.updating) {
const show = lines.slice(0, LEGEND_CHIP_CAP) const show = model.lines.slice(0, LEGEND_CHIP_CAP)
const extra = Math.max(0, lines.length - LEGEND_CHIP_CAP) const extra = Math.max(0, model.lines.length - LEGEND_CHIP_CAP)
const unitSuffix = units ? ` ${units}` : '' const unitSuffix = model.units ? ` ${model.units}` : ''
const nextHtml = const nextHtml =
show show
.map((line) => { .map((line) => {
@@ -644,6 +815,10 @@ export function createMetricsDashboard(opts) {
}) })
} }
} }
if (focus.isOpen() && focus.currentId() === id) {
syncFocusChrome(id)
focus.refresh()
}
} }
/** /**
@@ -1384,6 +1559,7 @@ export function createMetricsDashboard(opts) {
<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-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-related-btn" title="Find related">⇢</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> <button type="button" class="btn btn-ghost metric-type-btn" title="${escapeAttr(chartModeHint(card.mode))}">${escapeHtml(CHART_MODE_LABEL[card.mode] || card.mode)}</button>
<button type="button" class="btn btn-ghost metric-focus-btn" title="Fullscreen (double-click)" aria-label="Fullscreen">⛶</button>
<span class="metric-card-status muted"></span> <span class="metric-card-status muted"></span>
</div> </div>
</header> </header>
@@ -1391,6 +1567,7 @@ export function createMetricsDashboard(opts) {
<canvas height="${height}"></canvas> <canvas height="${height}"></canvas>
<div class="metric-highlight-band hidden" aria-hidden="true"></div> <div class="metric-highlight-band hidden" aria-hidden="true"></div>
<span class="metric-weight-chip hidden"></span> <span class="metric-weight-chip hidden"></span>
<button type="button" class="metric-focus-fab" title="Fullscreen" aria-label="Open fullscreen">⛶</button>
</div> </div>
<div class="metric-card-legend"></div> <div class="metric-card-legend"></div>
<div class="metric-card-stats hidden"></div> <div class="metric-card-stats hidden"></div>
@@ -1421,6 +1598,12 @@ export function createMetricsDashboard(opts) {
ev.stopPropagation() ev.stopPropagation()
cycleChartType(id) cycleChartType(id)
}) })
const openFocusHandler = (ev) => {
ev.stopPropagation()
openFocus(id)
}
article.querySelector('.metric-focus-btn')?.addEventListener('click', openFocusHandler)
article.querySelector('.metric-focus-fab')?.addEventListener('click', openFocusHandler)
bindResizeHandle(article) bindResizeHandle(article)
article.querySelector('.metric-card-title')?.addEventListener('click', (ev) => { article.querySelector('.metric-card-title')?.addEventListener('click', (ev) => {
ev.stopPropagation() ev.stopPropagation()
@@ -1437,14 +1620,9 @@ export function createMetricsDashboard(opts) {
}) })
article.addEventListener('dblclick', (ev) => { article.addEventListener('dblclick', (ev) => {
if (ev.target instanceof HTMLElement && ev.target.closest('.metric-card-actions')) return if (ev.target instanceof HTMLElement && ev.target.closest('.metric-card-actions')) return
article.classList.add('expanded') if (ev.target instanceof HTMLElement && ev.target.closest('.metric-resize')) return
const statsEl = article.querySelector('.metric-card-stats') ev.preventDefault()
if (statsEl) { openFocus(id)
statsEl.classList.remove('hidden')
const { dims, hidden } = sortedDims(card, id)
statsEl.innerHTML = renderStatsHtml(card, dims, hidden, card.meta?.units || '')
}
showRelated(id)
}) })
state.cardEls.set(id, article) state.cardEls.set(id, article)
state.observer?.observe(article) state.observer?.observe(article)
@@ -1822,6 +2000,17 @@ export function createMetricsDashboard(opts) {
} }
return return
} }
if (focus.isOpen()) {
if (ev.key === ' ' || ev.code === 'Space') {
ev.preventDefault()
setPlaying(!state.playing)
} else if (ev.key >= '1' && ev.key <= '5') {
const presets = ['1m', '5m', '15m', '1h', '6h']
setPreset(presets[Number(ev.key) - 1])
}
// Esc / arrows handled by chart-focus
return
}
if (ev.key === ' ' || ev.code === 'Space') { if (ev.key === ' ' || ev.code === 'Space') {
ev.preventDefault() ev.preventDefault()
setPlaying(!state.playing) setPlaying(!state.playing)
@@ -1930,6 +2119,8 @@ export function createMetricsDashboard(opts) {
redrawVisible, redrawVisible,
scrollToChart, scrollToChart,
focusChartAt, focusChartAt,
openFocus,
closeFocus: () => focus.close(),
showRelated, showRelated,
clearRelated, clearRelated,
setBoardOnly, setBoardOnly,
+338 -3
View File
@@ -772,13 +772,42 @@ body.is-offline .offline-banner:not(.hidden) {
.chart-panel header { .chart-panel header {
display: flex; display: flex;
align-items: baseline; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
margin-bottom: 4px; margin-bottom: 4px;
flex-shrink: 0; flex-shrink: 0;
} }
.chart-panel--focusable {
position: relative;
cursor: pointer;
transition:
border-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
}
.chart-panel--focusable:hover {
border-color: color-mix(in srgb, var(--accent-primary) 32%, var(--border-color));
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.2);
}
.chart-panel .chart-focus-btn {
opacity: 0;
min-width: 1.8rem !important;
padding: 2px 6px !important;
font-size: 12px !important;
margin-left: auto;
flex-shrink: 0;
transition: opacity 0.15s ease, background 0.15s ease;
}
.chart-panel--focusable:hover .chart-focus-btn,
.chart-panel--focusable:focus-within .chart-focus-btn {
opacity: 1;
}
.chart-panel h3 { .chart-panel h3 {
margin: 0; margin: 0;
font-size: 12px; font-size: 12px;
@@ -1536,7 +1565,7 @@ button.metrics-tf.thin-history:not(.active) {
padding: 12px 14px 8px; padding: 12px 14px 8px;
border-radius: 14px; border-radius: 14px;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 48%), linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 42%),
var(--bg-secondary); var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
min-height: 0; min-height: 0;
@@ -1557,6 +1586,59 @@ button.metrics-tf.thin-history:not(.active) {
border-color: color-mix(in srgb, var(--accent-primary) 42%, var(--border-color)); border-color: color-mix(in srgb, var(--accent-primary) 42%, var(--border-color));
} }
.metric-card.is-focused {
border-color: color-mix(in srgb, var(--accent-primary) 55%, var(--border-color));
box-shadow: var(--shadow-glow);
}
.metric-focus-fab {
position: absolute;
top: 8px;
left: 8px;
z-index: 4;
width: 30px;
height: 30px;
border: 1px solid var(--border-strong);
border-radius: 9px;
background: color-mix(in srgb, var(--bg-elevated) 88%, transparent);
color: var(--text-secondary);
font-size: 13px;
line-height: 1;
cursor: pointer;
opacity: 0;
transform: translateY(2px);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
transition:
opacity 0.16s ease,
transform 0.16s ease,
color 0.16s ease,
border-color 0.16s ease;
}
.metric-card:hover .metric-focus-fab,
.metric-card:focus-within .metric-focus-fab {
opacity: 1;
transform: translateY(0);
}
.metric-focus-fab:hover {
color: var(--accent-primary);
border-color: color-mix(in srgb, var(--accent-primary) 45%, var(--border-color));
}
.metric-focus-btn {
min-width: 1.8rem !important;
padding: 2px 6px !important;
font-size: 12px !important;
opacity: 0.55;
}
.metric-card:hover .metric-focus-btn,
.metric-card:focus-within .metric-focus-btn {
opacity: 1;
}
.metric-card[data-chart-mode='pie'] canvas { .metric-card[data-chart-mode='pie'] canvas {
cursor: default; cursor: default;
} }
@@ -1780,7 +1862,14 @@ html[data-theme='light'] .metric-card canvas {
.metric-card-actions { .metric-card-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 4px;
flex-shrink: 0;
}
.metric-card-actions .btn {
min-height: 26px;
padding: 2px 7px !important;
font-size: 11px !important;
} }
.metric-card canvas.panning { .metric-card canvas.panning {
@@ -2827,3 +2916,249 @@ code {
opacity: 1; opacity: 1;
} }
} }
/* ─── Fullscreen chart focus ─── */
body.chart-focus-open {
overflow: hidden;
}
.chart-focus {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: stretch;
justify-content: center;
padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right))
max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left));
opacity: 0;
pointer-events: none;
transition: opacity 0.18s ease;
}
.chart-focus.hidden {
display: none;
}
.chart-focus.is-open {
opacity: 1;
pointer-events: auto;
}
.chart-focus-backdrop {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 80% 60% at 50% 0%, rgba(52, 211, 153, 0.08), transparent 55%),
rgba(4, 6, 10, 0.72);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
html[data-theme='light'] .chart-focus-backdrop {
background:
radial-gradient(ellipse 80% 60% at 50% 0%, rgba(52, 211, 153, 0.1), transparent 55%),
rgba(243, 245, 248, 0.78);
}
.chart-focus-stage {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
width: min(1280px, 100%);
max-height: 100%;
margin: auto;
padding: 16px 18px 12px;
border-radius: 18px;
border: 1px solid var(--border-strong);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 36%),
var(--bg-secondary);
box-shadow: var(--shadow-md), 0 0 0 1px rgba(52, 211, 153, 0.08);
transform: translateY(10px) scale(0.985);
transition: transform 0.2s ease;
}
.chart-focus.is-open .chart-focus-stage {
transform: translateY(0) scale(1);
}
.chart-focus-head {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 10px;
flex-shrink: 0;
}
.chart-focus-titles {
min-width: 0;
flex: 1;
}
.chart-focus-kicker {
margin: 0 0 2px;
font-size: 11px;
font-family: var(--font-mono);
letter-spacing: 0.02em;
}
.chart-focus-head h2 {
margin: 0;
font-size: clamp(18px, 2.4vw, 26px);
font-weight: 650;
letter-spacing: -0.02em;
color: var(--text-primary);
}
.chart-focus-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
margin: 4px 0 0;
font-size: 11px;
font-family: var(--font-mono);
}
.chart-focus-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
justify-content: flex-end;
}
.chart-focus-actions .btn.is-active {
color: var(--accent-primary);
border-color: color-mix(in srgb, var(--accent-primary) 40%, var(--border-color));
}
.chart-focus-close {
min-width: 2.2rem !important;
font-size: 14px !important;
flex-shrink: 0;
}
.chart-focus-body {
position: relative;
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template-columns: 40px 1fr 40px;
gap: 4px;
align-items: stretch;
}
.chart-focus-canvas-wrap {
min-height: clamp(280px, 58vh, 720px);
border-radius: 12px;
overflow: hidden;
background: rgba(0, 0, 0, 0.22);
border: 1px solid var(--border-color);
}
html[data-theme='light'] .chart-focus-canvas-wrap {
background: rgba(15, 23, 42, 0.03);
}
.chart-focus-canvas-wrap canvas {
width: 100%;
height: 100%;
display: block;
}
.chart-focus-nav {
align-self: center;
width: 36px;
height: 64px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: color-mix(in srgb, var(--bg-elevated) 80%, transparent);
color: var(--text-secondary);
font-size: 28px;
line-height: 1;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.chart-focus-nav:hover:not(:disabled) {
color: var(--accent-primary);
border-color: color-mix(in srgb, var(--accent-primary) 40%, var(--border-color));
background: var(--bg-hover);
}
.chart-focus-nav:disabled,
.chart-focus-nav.hidden {
opacity: 0;
pointer-events: none;
}
.chart-focus-foot {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 10px;
flex-shrink: 0;
}
.chart-focus-legend {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 22px;
flex: 1;
}
.chart-focus-hint {
margin: 0;
font-size: 11px;
white-space: nowrap;
}
.chart-focus-hint kbd {
display: inline-block;
padding: 1px 5px;
border-radius: 5px;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
font-family: var(--font-mono);
font-size: 10px;
}
.page-hint-ico {
opacity: 0.85;
}
@media (max-width: 720px) {
.chart-focus-body {
grid-template-columns: 1fr;
}
.chart-focus-nav {
display: none;
}
.chart-focus-stage {
padding: 12px;
border-radius: 14px;
}
.chart-focus-hint {
white-space: normal;
}
.chart-panel .chart-focus-btn,
.metric-focus-fab,
.metric-focus-btn {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.chart-focus,
.chart-focus-stage,
.metric-focus-fab,
.metric-card,
.chart-panel--focusable {
transition: none !important;
}
}