Files
peardata/ui/custom-dashboard.js
T
Raven Scott 0699c42c28
CI / test (push) Successful in 2m42s
Release rolling / release (push) Canceled after 3m44s
Custom Dashboards - Agent Driven Dashboards
2026-07-30 16:48:28 -04:00

672 lines
19 KiB
JavaScript

/**
* 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<CustomDashboard> & { 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<string|DashTile>} 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<string, object>,
* request: (method: string, args?: object) => Promise<any>,
* getStore: () => ReturnType<typeof createDashboardStore>,
* persist: (patch: { customDashboards?: any[], activeDashboardId?: string|null }) => void,
* isConnected?: () => boolean,
* log?: (msg: string) => void,
* }} opts
*/
export function createCustomDashboardView(opts) {
let editing = false
/** @type {ReturnType<typeof setInterval>|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 = `<p class="muted dashboards-list-empty">No dashboards yet. Create one or ask QVAC to build one.</p>`
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 = `<strong>${esc(d.name)}</strong><span class="muted">${d.tiles.length} charts</span>`
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 =
`<option value="">Add chart…</option>` +
ids
.slice(0, 800)
.map((id) => {
const t = catalog[id]?.title || id
return `<option value="${escAttr(id)}">${esc(id)}${esc(t)}</option>`
})
.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 = `
<header class="dashboard-tile-head">
<div>
<h3>${esc(tile.title || meta.title || tile.chart)}</h3>
<span class="muted dashboard-tile-id">${esc(tile.chart)}</span>
</div>
<div class="dashboard-tile-actions ${editing ? '' : 'hidden'}">
<select class="dashboard-tile-mode" title="Chart type" aria-label="Chart type">
${['line', 'area', 'stacked', 'bar', 'multibar', 'pie']
.map(
(m) =>
`<option value="${m}"${m === mode ? ' selected' : ''}>${CHART_MODE_LABEL[m] || m}</option>`
)
.join('')}
</select>
<button type="button" class="btn btn-ghost dashboard-tile-remove" title="Remove">✕</button>
</div>
</header>
<canvas class="dashboard-tile-canvas" height="120"></canvas>
<footer class="muted dashboard-tile-foot">—</footer>
`
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function escAttr(s) {
return esc(s).replace(/'/g, '&#39;')
}
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)
}