/** * Custom Dashboards — user + agent editable live chart boards. * * Storage shape (settings.customDashboards): * { * id, name, description, * tiles: [{ id, chart, mode?, title? }], * createdAt, updatedAt * } */ import { drawChart, seriesColor } from './charts.js' import { normalizeChartMode, CHART_MODE_LABEL } from '../shared/chart-types.js' /** * @typedef {{ * id: string, * chart: string, * mode?: string, * title?: string, * }} DashTile * * @typedef {{ * id: string, * name: string, * description?: string, * tiles: DashTile[], * createdAt: number, * updatedAt: number, * }} CustomDashboard */ function uid(prefix = 'd') { return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` } /** * @param {Partial & { name?: string }} partial * @returns {CustomDashboard} */ export function createDashboardRecord(partial = {}) { const now = Date.now() return { id: partial.id || uid('dash'), name: String(partial.name || 'New dashboard').slice(0, 80), description: String(partial.description || '').slice(0, 400), tiles: Array.isArray(partial.tiles) ? partial.tiles.map(normalizeTile).filter(Boolean) : [], createdAt: partial.createdAt || now, updatedAt: now, } } /** @param {any} t @returns {DashTile|null} */ function normalizeTile(t) { if (!t) return null if (typeof t === 'string') { const chart = t.trim() if (!chart) return null return { id: uid('tile'), chart, mode: 'area' } } const chart = String(t.chart || t.chartId || '').trim() if (!chart) return null return { id: String(t.tileId || t.uid || uid('tile')), chart, mode: t.mode ? normalizeChartMode(t.mode) : undefined, title: t.title ? String(t.title).slice(0, 120) : undefined, } } /** * Pure store helpers (no DOM). * @param {CustomDashboard[]} list * @param {string|null|undefined} activeId */ export function createDashboardStore(list = [], activeId = null) { /** @type {CustomDashboard[]} */ let dashboards = Array.isArray(list) ? list.map((d) => createDashboardRecord(d)) : [] let active = activeId && dashboards.some((d) => d.id === activeId) ? activeId : dashboards[0]?.id || null function all() { return dashboards.map((d) => ({ ...d, tiles: d.tiles.map((t) => ({ ...t })) })) } function get(id) { return dashboards.find((d) => d.id === id) || null } function getActive() { return (active && get(active)) || dashboards[0] || null } function setActive(id) { if (!id || !get(id)) return getActive() active = id return get(id) } function upsert(partial) { const existing = partial.id ? get(partial.id) : null if (existing) { existing.name = partial.name != null ? String(partial.name).slice(0, 80) : existing.name existing.description = partial.description != null ? String(partial.description).slice(0, 400) : existing.description if (Array.isArray(partial.tiles)) { existing.tiles = partial.tiles.map(normalizeTile).filter(Boolean) } existing.updatedAt = Date.now() return { ...existing, tiles: existing.tiles.map((t) => ({ ...t })) } } const created = createDashboardRecord(partial) dashboards.push(created) if (!active) active = created.id return created } function remove(id) { const i = dashboards.findIndex((d) => d.id === id) if (i < 0) return false dashboards.splice(i, 1) if (active === id) active = dashboards[0]?.id || null return true } /** * @param {string} dashId * @param {Array} charts * @param {{ mode?: string, replace?: boolean }} [opts] */ function addTiles(dashId, charts, opts = {}) { const d = get(dashId) if (!d) return { ok: false, error: 'dashboard not found' } const incoming = (Array.isArray(charts) ? charts : [charts]).map((c) => { if (typeof c === 'string') return normalizeTile({ chart: c, mode: opts.mode }) return normalizeTile({ ...c, mode: c.mode || opts.mode }) }).filter(Boolean) if (opts.replace) d.tiles = incoming else { const have = new Set(d.tiles.map((t) => t.chart)) for (const t of incoming) { if (!have.has(t.chart)) { d.tiles.push(t) have.add(t.chart) } else if (opts.mode) { const hit = d.tiles.find((x) => x.chart === t.chart) if (hit) hit.mode = normalizeChartMode(opts.mode) } } } d.updatedAt = Date.now() return { ok: true, dashboard: snapshot(d) } } function removeTiles(dashId, charts) { const d = get(dashId) if (!d) return { ok: false, error: 'dashboard not found' } const removeSet = new Set( (Array.isArray(charts) ? charts : [charts]).map(String) ) d.tiles = d.tiles.filter((t) => !removeSet.has(t.chart) && !removeSet.has(t.id)) d.updatedAt = Date.now() return { ok: true, dashboard: snapshot(d) } } function snapshot(d) { return { ...d, tiles: d.tiles.map((t) => ({ ...t })) } } function activeId() { return active } return { all, get, getActive, setActive, upsert, remove, addTiles, removeTiles, activeId, /** @returns {{ dashboards: CustomDashboard[], activeDashboardId: string|null }} */ serialize() { return { dashboards: all(), activeDashboardId: active, } }, } } /** * @param {{ * els: { * root: HTMLElement|null, * list: HTMLElement|null, * title: HTMLElement|null, * grid: HTMLElement|null, * empty: HTMLElement|null, * nameInput?: HTMLInputElement|null, * descInput?: HTMLInputElement|null, * addSelect?: HTMLSelectElement|null, * addBtn?: HTMLElement|null, * newBtn?: HTMLElement|null, * deleteBtn?: HTMLElement|null, * saveBtn?: HTMLElement|null, * editToggle?: HTMLElement|null, * meta?: HTMLElement|null, * }, * getCatalog: () => Record, * request: (method: string, args?: object) => Promise, * getStore: () => ReturnType, * persist: (patch: { customDashboards?: any[], activeDashboardId?: string|null }) => void, * isConnected?: () => boolean, * log?: (msg: string) => void, * }} opts */ export function createCustomDashboardView(opts) { let editing = false /** @type {ReturnType|null} */ let pollTimer = null let afterSeconds = 300 function store() { return opts.getStore() } function persist() { const s = store().serialize() opts.persist({ customDashboards: s.dashboards, activeDashboardId: s.activeDashboardId, }) } function enter() { editing = false syncEditChrome() render() startPoll() } function leave() { stopPoll() } function startPoll() { stopPoll() pollTimer = setInterval(() => { if (opts.isConnected?.() === false) return paintAllTiles().catch(() => {}) }, 3000) } function stopPoll() { if (pollTimer) clearInterval(pollTimer) pollTimer = null } function render() { renderList() renderBoard() } function renderList() { const host = opts.els.list if (!host) return host.innerHTML = '' const all = store().all() const active = store().activeId() if (!all.length) { host.innerHTML = `

No dashboards yet. Create one or ask QVAC to build one.

` return } for (const d of all) { const btn = document.createElement('button') btn.type = 'button' btn.className = `dashboard-list-item${d.id === active ? ' active' : ''}` btn.innerHTML = `${esc(d.name)}${d.tiles.length} charts` btn.title = d.description || d.name btn.addEventListener('click', () => { store().setActive(d.id) persist() render() }) host.appendChild(btn) } } function renderBoard() { const d = store().getActive() const title = opts.els.title const grid = opts.els.grid const empty = opts.els.empty const meta = opts.els.meta if (title) title.textContent = d?.name || 'Dashboards' if (meta) { meta.textContent = d ? `${d.tiles.length} tiles · ${d.description || 'Custom board'}${editing ? ' · editing' : ''}` : 'Create a dashboard to pin live charts' } if (opts.els.nameInput) opts.els.nameInput.value = d?.name || '' if (opts.els.descInput) opts.els.descInput.value = d?.description || '' fillAddSelect() if (!grid) return grid.innerHTML = '' if (!d || !d.tiles.length) { empty?.classList.remove('hidden') return } empty?.classList.add('hidden') for (const tile of d.tiles) { grid.appendChild(buildTileEl(d.id, tile)) } paintAllTiles().catch(() => {}) } function fillAddSelect() { const sel = opts.els.addSelect if (!sel) return const catalog = opts.getCatalog() || {} const ids = Object.keys(catalog).sort() const prev = sel.value sel.innerHTML = `` + ids .slice(0, 800) .map((id) => { const t = catalog[id]?.title || id return `` }) .join('') if (prev && [...sel.options].some((o) => o.value === prev)) sel.value = prev } function buildTileEl(dashId, tile) { const catalog = opts.getCatalog() || {} const meta = catalog[tile.chart] || {} const article = document.createElement('article') article.className = 'dashboard-tile' article.dataset.tileId = tile.id article.dataset.chart = tile.chart const mode = tile.mode || 'area' article.innerHTML = `

${esc(tile.title || meta.title || tile.chart)}

${esc(tile.chart)}
` article.querySelector('.dashboard-tile-remove')?.addEventListener('click', (ev) => { ev.stopPropagation() store().removeTiles(dashId, [tile.chart]) persist() render() }) article.querySelector('.dashboard-tile-mode')?.addEventListener('change', (ev) => { const v = /** @type {HTMLSelectElement} */ (ev.target).value const d = store().get(dashId) const t = d?.tiles.find((x) => x.id === tile.id) if (t) { t.mode = normalizeChartMode(v) d.updatedAt = Date.now() persist() paintTile(article, t).catch(() => {}) } }) return article } async function paintAllTiles() { const grid = opts.els.grid if (!grid) return const d = store().getActive() if (!d) return const jobs = [] for (const el of grid.querySelectorAll('.dashboard-tile')) { const chart = el.getAttribute('data-chart') || '' const tile = d.tiles.find((t) => t.chart === chart) if (tile) jobs.push(paintTile(/** @type {HTMLElement} */ (el), tile)) } await Promise.all(jobs) } /** * @param {HTMLElement} el * @param {DashTile} tile */ async function paintTile(el, tile) { const canvas = /** @type {HTMLCanvasElement|null} */ (el.querySelector('canvas')) const foot = el.querySelector('.dashboard-tile-foot') if (!canvas) return if (opts.isConnected?.() === false) { if (foot) foot.textContent = 'Not connected' return } try { const q = await opts.request('queryData', { chart: tile.chart, after: -afterSeconds, points: Math.min(180, afterSeconds), group: 'average', }) const labels = (q.labels || []).filter((l) => l && l !== 'time') const data = Array.isArray(q.data) ? q.data : [] if (!labels.length || data.length < 2) { if (foot) foot.textContent = 'No data' drawChart(canvas, [], { emptyMessage: 'No data', maxPoints: 60 }) return } /** @type {Array<{ values: number[], color: string, label: string }>} */ const lines = [] for (let di = 0; di < Math.min(4, labels.length); di++) { const full = data.map((row) => { const v = Number(row[di + 1]) return Number.isFinite(v) ? v : 0 }) if (full.every((v) => v === 0) && full.length > 2) { // keep — may be legit } lines.push({ values: full, color: seriesColor(di), label: labels[di], }) } const mode = normalizeChartMode(tile.mode || 'area') drawChart(canvas, lines, { mode: mode === 'pie' || mode === 'bar' || mode === 'multibar' ? mode : mode === 'stacked' ? 'stacked' : mode, maxPoints: 120, showYAxis: true, padLeft: 36, emptyMessage: 'No data', }) const last = lines[0]?.values[lines[0].values.length - 1] if (foot) { foot.textContent = `${lines.map((l) => l.label).join(', ')} · last ${fmt(last)} · ${afterSeconds}s` } } catch (err) { if (foot) foot.textContent = err?.message || 'query failed' } } function syncEditChrome() { const on = editing opts.els.editToggle?.classList.toggle('active', on) opts.els.root?.classList.toggle('is-editing', on) opts.els.nameInput?.closest('.dashboard-edit-fields')?.classList.toggle('hidden', !on) for (const el of opts.els.grid?.querySelectorAll('.dashboard-tile-actions') || []) { el.classList.toggle('hidden', !on) } } function bind() { opts.els.newBtn?.addEventListener('click', () => { const d = store().upsert({ name: `Dashboard ${store().all().length + 1}`, description: '', tiles: [], }) store().setActive(d.id) persist() editing = true syncEditChrome() render() }) opts.els.deleteBtn?.addEventListener('click', () => { const d = store().getActive() if (!d) return if (!confirm(`Delete dashboard “${d.name}”?`)) return store().remove(d.id) persist() render() }) opts.els.saveBtn?.addEventListener('click', () => { const d = store().getActive() if (!d) return store().upsert({ id: d.id, name: opts.els.nameInput?.value || d.name, description: opts.els.descInput?.value || '', }) persist() editing = false syncEditChrome() render() }) opts.els.editToggle?.addEventListener('click', () => { editing = !editing syncEditChrome() renderBoard() }) opts.els.addBtn?.addEventListener('click', () => { const d = store().getActive() const chart = opts.els.addSelect?.value if (!d || !chart) return store().addTiles(d.id, [chart], { mode: 'area' }) persist() render() }) } // ── Agent API ────────────────────────────────────────────────────────── function listDashboards() { return { ok: true, activeDashboardId: store().activeId(), dashboards: store().all().map((d) => ({ id: d.id, name: d.name, description: d.description, tileCount: d.tiles.length, charts: d.tiles.map((t) => t.chart), updatedAt: d.updatedAt, })), } } function createDashboard(args = {}) { const tiles = parseTilesArg(args) const d = store().upsert({ name: args.name || 'Agent dashboard', description: args.description || '', tiles, }) store().setActive(d.id) persist() render() return { ok: true, dashboard: store().get(d.id) } } function updateDashboard(args = {}) { const id = args.id || args.dashboardId || store().activeId() if (!id || !store().get(id)) return { ok: false, error: 'dashboard not found' } const patch = { id } if (args.name != null) patch.name = args.name if (args.description != null) patch.description = args.description if (args.tiles != null) patch.tiles = parseTilesArg(args) store().upsert(patch) persist() render() return { ok: true, dashboard: store().get(id) } } function deleteDashboard(args = {}) { const id = args.id || args.dashboardId if (!id) return { ok: false, error: 'id required' } const ok = store().remove(id) if (!ok) return { ok: false, error: 'dashboard not found' } persist() render() return { ok: true, deleted: id, activeDashboardId: store().activeId() } } function addDashboardCharts(args = {}) { const id = args.id || args.dashboardId || store().activeId() if (!id) return { ok: false, error: 'no active dashboard — create one first' } const charts = parseChartsArg(args) if (!charts.length) return { ok: false, error: 'charts required' } const res = store().addTiles(id, charts, { mode: args.mode, replace: Boolean(args.replace), }) persist() render() return res } function removeDashboardCharts(args = {}) { const id = args.id || args.dashboardId || store().activeId() if (!id) return { ok: false, error: 'no active dashboard' } const charts = parseChartsArg(args) if (!charts.length) return { ok: false, error: 'charts required' } const res = store().removeTiles(id, charts) persist() render() return res } function openDashboard(args = {}) { const id = args.id || args.dashboardId if (id) store().setActive(id) persist() render() return { ok: true, activeDashboardId: store().activeId(), dashboard: store().getActive(), } } function parseChartsArg(args) { if (Array.isArray(args.charts)) return args.charts.map(String) if (args.chart) return [String(args.chart)] if (typeof args.charts === 'string') { return args.charts.split(/[\s,]+/).filter(Boolean) } if (typeof args.tiles === 'string') { return args.tiles.split(/[\s,]+/).filter(Boolean) } return [] } function parseTilesArg(args) { if (Array.isArray(args.tiles)) { return args.tiles.map(normalizeTile).filter(Boolean) } return parseChartsArg(args).map((chart) => normalizeTile({ chart, mode: args.mode || 'area' }) ) } bind() return { enter, leave, render, listDashboards, createDashboard, updateDashboard, deleteDashboard, addDashboardCharts, removeDashboardCharts, openDashboard, getStore: store, } } function esc(s) { return String(s || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } function escAttr(s) { return esc(s).replace(/'/g, ''') } function fmt(v) { const n = Number(v) if (!Number.isFinite(n)) return '—' if (Math.abs(n) >= 100) return n.toFixed(0) if (Math.abs(n) >= 10) return n.toFixed(1) return n.toFixed(2) }