Custom Dashboards - Agent Driven Dashboards
This commit is contained in:
@@ -27,6 +27,10 @@ import { defaultModeFromMeta } from './shared/chart-types.js'
|
||||
import { createMetricsDashboard } from './ui/dashboard.js'
|
||||
import { getChartFocus } from './ui/chart-focus.js'
|
||||
import { createQvacView } from './ui/qvac/index.js'
|
||||
import {
|
||||
createDashboardStore,
|
||||
createCustomDashboardView,
|
||||
} from './ui/custom-dashboard.js'
|
||||
import {
|
||||
buildFleetRoster,
|
||||
summarizeFleet,
|
||||
@@ -293,6 +297,39 @@ const dataManager = createDataManager({
|
||||
log: (msg) => log(msg),
|
||||
})
|
||||
|
||||
const dashboardStore = createDashboardStore(
|
||||
settings.customDashboards || [],
|
||||
settings.activeDashboardId || null
|
||||
)
|
||||
|
||||
const customDashboardView = createCustomDashboardView({
|
||||
els: {
|
||||
root: $('dashboard-view'),
|
||||
list: $('dashboard-list'),
|
||||
title: $('dashboard-title'),
|
||||
grid: $('dashboard-grid'),
|
||||
empty: $('dashboard-empty'),
|
||||
nameInput: /** @type {HTMLInputElement|null} */ ($('dashboard-name')),
|
||||
descInput: /** @type {HTMLInputElement|null} */ ($('dashboard-desc')),
|
||||
addSelect: /** @type {HTMLSelectElement|null} */ ($('dashboard-add-select')),
|
||||
addBtn: $('dashboard-add-btn'),
|
||||
newBtn: $('dashboard-new'),
|
||||
deleteBtn: $('dashboard-delete'),
|
||||
saveBtn: $('dashboard-save'),
|
||||
editToggle: $('dashboard-edit'),
|
||||
meta: $('dashboard-meta'),
|
||||
},
|
||||
getCatalog: () => chartCatalog,
|
||||
request: (m, a) => manager.request(m, a || {}),
|
||||
getStore: () => dashboardStore,
|
||||
persist: (patch) => {
|
||||
Object.assign(settings, patch)
|
||||
persist(patch)
|
||||
},
|
||||
isConnected: () => Boolean(manager.active?.connected),
|
||||
log: (msg) => log(msg),
|
||||
})
|
||||
|
||||
const qvacView = createQvacView({
|
||||
els: {
|
||||
root: $('qvac-view'),
|
||||
@@ -364,6 +401,19 @@ const qvacView = createQvacView({
|
||||
correlateAround: (ts, o) => metricsDashboard.correlateAround?.(ts, o),
|
||||
runMetricCorrelations: (o) => metricsDashboard.runMetricCorrelations?.(o),
|
||||
},
|
||||
dashboards: {
|
||||
list: () => customDashboardView.listDashboards(),
|
||||
create: (a) => customDashboardView.createDashboard(a),
|
||||
update: (a) => customDashboardView.updateDashboard(a),
|
||||
remove: (a) => customDashboardView.deleteDashboard(a),
|
||||
addCharts: (a) => customDashboardView.addDashboardCharts(a),
|
||||
removeCharts: (a) => customDashboardView.removeDashboardCharts(a),
|
||||
open: (a) => {
|
||||
const r = customDashboardView.openDashboard(a)
|
||||
showView('dashboard')
|
||||
return r
|
||||
},
|
||||
},
|
||||
log: (msg) => log(msg),
|
||||
})
|
||||
|
||||
@@ -1417,6 +1467,8 @@ function showView(name) {
|
||||
metricsDashboard.render()
|
||||
requestAnimationFrame(() => metricsDashboard.redrawVisible())
|
||||
}
|
||||
if (name === 'dashboard') customDashboardView.enter()
|
||||
else customDashboardView.leave?.()
|
||||
if (name === 'logs') logsView.enter()
|
||||
if (name === 'processes') processesView.enter()
|
||||
if (name === 'qvac') qvacView.enter()
|
||||
|
||||
@@ -41,6 +41,15 @@ export const SETTINGS_LOCALSTORAGE_KEY = 'peardata.settings.v1'
|
||||
* qvacSubAgents?: boolean,
|
||||
* qvacMaxSubAgents?: number,
|
||||
* qvacToolDepth?: 'auto'|'core'|'deep',
|
||||
* customDashboards?: Array<{
|
||||
* id: string,
|
||||
* name: string,
|
||||
* description?: string,
|
||||
* tiles: Array<{ id: string, chart: string, mode?: string, title?: string }>,
|
||||
* createdAt?: number,
|
||||
* updatedAt?: number,
|
||||
* }>,
|
||||
* activeDashboardId?: string|null,
|
||||
* }} UiSettings */
|
||||
|
||||
/** @returns {UiSettings} */
|
||||
@@ -74,6 +83,8 @@ export function defaultSettings() {
|
||||
qvacSubAgents: true,
|
||||
qvacMaxSubAgents: 3,
|
||||
qvacToolDepth: 'auto',
|
||||
customDashboards: [],
|
||||
activeDashboardId: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -221,6 +221,26 @@ The model can drive the Charts wall in real time:
|
||||
|
||||
Chat also embeds **sparklines** for charts the tools touched (click opens the full wall).
|
||||
|
||||
### Custom dashboards
|
||||
|
||||
Saved boards live under the **Dashboard** nav tab (below Charts). Users edit them in the UI; the model can also:
|
||||
|
||||
| Tool | Effect |
|
||||
|------|--------|
|
||||
| `list_dashboards` | List boards |
|
||||
| `create_dashboard` | Create + open with chart tiles |
|
||||
| `update_dashboard` / `delete_dashboard` | Rename or remove |
|
||||
| `add_dashboard_charts` / `remove_dashboard_charts` | Edit tiles |
|
||||
| `open_dashboard` | Switch to Dashboard tab |
|
||||
|
||||
Boards persist in desktop settings (`customDashboards`).
|
||||
|
||||
### Multi-agent (dynamic)
|
||||
|
||||
With multi-agent **enabled** (default), specialists only run for **broad investigations**
|
||||
(“what’s wrong”, full health check, 3+ domains). Ordinary questions (graphs, one metric,
|
||||
alerts list, dashboards) stay **single-agent** — no “Dispatching multi-agent…” flash.
|
||||
|
||||
On context overflow the engine first drops to **core** tools, then drops tool schemas entirely and retries.
|
||||
|
||||
## Settings
|
||||
|
||||
+49
@@ -47,6 +47,9 @@
|
||||
<button type="button" class="nav-link" data-view="charts">
|
||||
<span class="nav-ico">▦</span><span class="nav-label">Charts</span>
|
||||
</button>
|
||||
<button type="button" class="nav-link" data-view="dashboard">
|
||||
<span class="nav-ico">▣</span><span class="nav-label">Dashboard</span>
|
||||
</button>
|
||||
<button type="button" class="nav-link" data-view="processes">
|
||||
<span class="nav-ico">⧉</span><span class="nav-label">Processes</span>
|
||||
</button>
|
||||
@@ -342,6 +345,52 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Custom dashboards -->
|
||||
<section id="dashboard-view" class="view hidden">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<p class="dash-kicker">Boards</p>
|
||||
<h1 class="dash-title" id="dashboard-title">Dashboard</h1>
|
||||
<p class="page-subtitle" id="dashboard-meta">Build custom live boards · QVAC can create and edit them too</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button type="button" id="dashboard-new" class="btn btn-ghost">New</button>
|
||||
<button type="button" id="dashboard-edit" class="btn btn-ghost" aria-pressed="false">Edit</button>
|
||||
<button type="button" id="dashboard-save" class="btn btn-ghost">Save</button>
|
||||
<button type="button" id="dashboard-delete" class="btn btn-ghost">Delete</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="dashboard-layout">
|
||||
<aside class="dashboard-sidebar" aria-label="Dashboards">
|
||||
<h3 class="dashboard-sidebar-title">Your boards</h3>
|
||||
<div id="dashboard-list" class="dashboard-list"></div>
|
||||
</aside>
|
||||
<div class="dashboard-main">
|
||||
<div class="dashboard-edit-fields hidden">
|
||||
<label class="settings-field">
|
||||
Name
|
||||
<input type="text" id="dashboard-name" maxlength="80" placeholder="Dashboard name" />
|
||||
</label>
|
||||
<label class="settings-field">
|
||||
Description
|
||||
<input type="text" id="dashboard-desc" maxlength="400" placeholder="Optional description" />
|
||||
</label>
|
||||
<div class="dashboard-add-row">
|
||||
<select id="dashboard-add-select" aria-label="Add chart">
|
||||
<option value="">Add chart…</option>
|
||||
</select>
|
||||
<button type="button" id="dashboard-add-btn" class="btn btn-primary">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dashboard-empty" class="dashboard-empty">
|
||||
<p><strong>No charts on this board yet.</strong></p>
|
||||
<p class="muted">Click <em>Edit</em> to add charts, or ask QVAC: “Create a dashboard with CPU, RAM, and disk IO”.</p>
|
||||
</div>
|
||||
<div id="dashboard-grid" class="dashboard-grid" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- QVAC local AI -->
|
||||
<section id="qvac-view" class="view hidden">
|
||||
<header class="page-header qvac-header">
|
||||
|
||||
@@ -0,0 +1,671 @@
|
||||
/**
|
||||
* 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, '&')
|
||||
.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)
|
||||
}
|
||||
+56
-18
@@ -132,32 +132,70 @@ export const AGENT_SPECS = [
|
||||
]
|
||||
|
||||
/**
|
||||
* Whether this turn should fan out sub-agents.
|
||||
* Dynamic multi-agent gate — **single-agent by default**.
|
||||
* Only fan out when the user clearly wants a broad investigation (or multi-domain).
|
||||
*
|
||||
* @param {string} userText
|
||||
* @param {{ subAgents?: boolean }} prefs
|
||||
* @param {{ subAgents?: boolean, subAgentMode?: 'auto'|'always'|'never' }} prefs
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function shouldUseSubAgents(userText, prefs = {}) {
|
||||
if (prefs.subAgents === false) return false
|
||||
const q = String(userText || '').toLowerCase()
|
||||
// Product how-tos: skip multi-agent (use local_knowledge only)
|
||||
if (prefs.subAgents === false || prefs.subAgentMode === 'never') return false
|
||||
if (prefs.subAgentMode === 'always' && prefs.subAgents !== false) {
|
||||
const q0 = String(userText || '').toLowerCase()
|
||||
// Still skip pure how-to / empty
|
||||
if (!q0.trim()) return false
|
||||
if (/how (do|to)|what is qvac|keyboard|time preset/.test(q0)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const q = String(userText || '').toLowerCase().trim()
|
||||
if (!q || q.length < 10) return false
|
||||
|
||||
// Product / how-to / chart-only / single tool asks → always single
|
||||
if (
|
||||
q.includes('how do') ||
|
||||
q.includes('how to') ||
|
||||
q.includes('what is qvac') ||
|
||||
q.includes('keyboard') ||
|
||||
q.includes('time preset')
|
||||
/how (do|to)|what is qvac|keyboard|time preset|retention work/.test(q)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// Default on for operational questions when feature enabled
|
||||
if (prefs.subAgents === true || prefs.subAgents == null) {
|
||||
return (
|
||||
/health|wrong|diagnos|investigat|summar|status|cpu|ram|memory|disk|alert|anomal|process|load|hot|spik|fleet|storage|retention|metric|chart|nginx|redis|docker|postgres|io\b/.test(
|
||||
q
|
||||
) || q.length < 4
|
||||
)
|
||||
}
|
||||
// UI chart/dashboard building without investigation language
|
||||
if (
|
||||
/\b(show|plot|graph|open|pin|filter|dashboard)\b/.test(q) &&
|
||||
!/\b(investigat|diagnos|what'?s wrong|root cause|health check|full (scan|check))\b/.test(q)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Explicit multi-agent / deep investigation
|
||||
if (
|
||||
/\b(multi[- ]?agent|investigat|investigation|diagnos(e|is|tic)?|what'?s wrong|whats wrong|what is wrong|root cause|deep dive|full (health|check|scan)|run (all|every) agent|spin up agent)\b/.test(
|
||||
q
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Broad host health summaries
|
||||
if (
|
||||
/\b(summarize host|host health|health (check|status|summary)|overall status|everything (wrong|ok)|whole (host|system))\b/.test(
|
||||
q
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Multi-domain operational question (3+ areas) → worth parallel specialists
|
||||
const domains = [
|
||||
/\b(cpu|load)\b/,
|
||||
/\b(ram|memory)\b/,
|
||||
/\b(disk|io|storage|iops)\b/,
|
||||
/\b(alert|anomal)/,
|
||||
/\b(process|pid|top)\b/,
|
||||
/\b(fleet|child peer)/,
|
||||
/\b(net|network)\b/,
|
||||
]
|
||||
const hits = domains.reduce((n, re) => n + (re.test(q) ? 1 : 0), 0)
|
||||
return hits >= 3
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,6 +63,7 @@ export function createQvacView(opts) {
|
||||
onOpenChart: opts.onOpenChart,
|
||||
onOpenView: opts.onOpenView,
|
||||
charts: opts.charts,
|
||||
dashboards: opts.dashboards,
|
||||
confirmAction: (msg) => {
|
||||
try {
|
||||
return typeof confirm === 'function' ? confirm(msg) : true
|
||||
|
||||
+5
-2
@@ -30,15 +30,17 @@ export function buildSystemPrompt(ctx = {}) {
|
||||
'- Do not claim cloud access; all inference is local via QVAC.',
|
||||
'- Prefer show_charts / open_chart / open_view when the user asks to see graphs or the UI.',
|
||||
multi
|
||||
? '- Multi-agent mode: specialist investigators may already have collected a briefing. Trust those facts; only call extra tools for gaps.'
|
||||
? '- Multi-agent is DYNAMIC: the host only fans out specialists for broad investigations (what\'s wrong, full health, multi-domain). Single questions use normal tools only.'
|
||||
: '',
|
||||
'',
|
||||
'Tool playbook (use these exact tool names and chart ids):',
|
||||
'- Host health / "what\'s wrong" / diagnose / investigate → investigate_host FIRST (one-shot findings).',
|
||||
' Lighter alternative: host_snapshot. Do NOT use md.health for general health.',
|
||||
'- "Show me a graph/chart/plot" → search_charts if needed, then show_charts with concrete chart ids.',
|
||||
' show_charts pins to the board, sets window (preset=5m|1h|…), optional mode=line|area|bar|pie, focus.',
|
||||
' show_charts pins to the Charts wall board, sets window (preset=5m|1h|…), optional mode=line|area|bar|pie.',
|
||||
' Example: show_charts charts=system.cpu,system.ram preset=15m mode=area boardOnly=true.',
|
||||
'- Custom Dashboard tab (saved boards) → create_dashboard name=… charts=system.cpu,system.ram',
|
||||
' then open_dashboard. Edit with add_dashboard_charts / remove_dashboard_charts / update_dashboard / list_dashboards.',
|
||||
'- Single chart focus → open_chart chart=system.cpu (optional pin, mode, preset, ts).',
|
||||
'- Pin/unpin board → pin_charts; change type → set_chart_type; window → set_time_window; search wall → filter_charts.',
|
||||
'- Related wall panel → show_related_ui; Metric Correlations UI → run_correlations.',
|
||||
@@ -69,6 +71,7 @@ export const SAMPLE_PROMPTS = [
|
||||
'Summarize host health right now',
|
||||
"What's wrong — investigate this host",
|
||||
'Show me live graphs for CPU and RAM (15m)',
|
||||
'Create a dashboard named SRE Core with CPU, RAM, load, and disk IO',
|
||||
'Plot disk / io charts as an area board',
|
||||
'Why might CPU be high?',
|
||||
'Show hot / spiking metrics and open them',
|
||||
|
||||
+180
-1
@@ -291,7 +291,7 @@ export const TOOL_DEFS = [
|
||||
name: 'open_view',
|
||||
tier: 'core',
|
||||
description:
|
||||
'Navigate desktop to a view: overview|charts|processes|alerts|logs|fleet|settings|qvac',
|
||||
'Navigate desktop to a view: overview|charts|dashboard|processes|alerts|logs|fleet|settings|qvac',
|
||||
parameters: params(
|
||||
{
|
||||
view: {
|
||||
@@ -300,6 +300,7 @@ export const TOOL_DEFS = [
|
||||
enum: [
|
||||
'overview',
|
||||
'charts',
|
||||
'dashboard',
|
||||
'processes',
|
||||
'alerts',
|
||||
'logs',
|
||||
@@ -313,6 +314,112 @@ export const TOOL_DEFS = [
|
||||
),
|
||||
},
|
||||
|
||||
// ── custom dashboards (user boards the agent can build/edit) ──────────
|
||||
{
|
||||
type: 'function',
|
||||
name: 'list_dashboards',
|
||||
tier: 'core',
|
||||
description: 'List custom dashboards (id, name, chart tiles).',
|
||||
parameters: params({}),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'create_dashboard',
|
||||
tier: 'core',
|
||||
description:
|
||||
'Create a custom live dashboard and open it. Pass name + charts (comma ids). Use for "build a dashboard with CPU and RAM".',
|
||||
parameters: params(
|
||||
{
|
||||
name: { type: 'string', description: 'Dashboard name' },
|
||||
description: { type: 'string', description: 'Optional description' },
|
||||
charts: {
|
||||
type: 'string',
|
||||
description: 'Comma/space chart ids e.g. system.cpu,system.ram,system.io',
|
||||
},
|
||||
mode: {
|
||||
type: 'string',
|
||||
description: 'Default tile mode line|area|bar|…',
|
||||
enum: ['line', 'area', 'stacked', 'bar', 'multibar', 'pie'],
|
||||
},
|
||||
},
|
||||
['name']
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'update_dashboard',
|
||||
tier: 'core',
|
||||
description: 'Rename/update a dashboard (id optional = active). Can replace tiles via charts list.',
|
||||
parameters: params({
|
||||
id: { type: 'string', description: 'Dashboard id' },
|
||||
name: { type: 'string', description: 'New name' },
|
||||
description: { type: 'string', description: 'New description' },
|
||||
charts: {
|
||||
type: 'string',
|
||||
description: 'If set, replace all tiles with these chart ids',
|
||||
},
|
||||
mode: { type: 'string', description: 'Mode when replacing tiles' },
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'delete_dashboard',
|
||||
tier: 'core',
|
||||
description: 'Delete a custom dashboard by id.',
|
||||
parameters: params(
|
||||
{
|
||||
id: { type: 'string', description: 'Dashboard id' },
|
||||
},
|
||||
['id']
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'add_dashboard_charts',
|
||||
tier: 'core',
|
||||
description: 'Add chart tiles to a dashboard (id optional = active board).',
|
||||
parameters: params(
|
||||
{
|
||||
id: { type: 'string', description: 'Dashboard id (default active)' },
|
||||
charts: {
|
||||
type: 'string',
|
||||
description: 'Comma/space chart ids to add',
|
||||
},
|
||||
mode: {
|
||||
type: 'string',
|
||||
description: 'Tile chart type',
|
||||
enum: ['line', 'area', 'stacked', 'bar', 'multibar', 'pie'],
|
||||
},
|
||||
},
|
||||
['charts']
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'remove_dashboard_charts',
|
||||
tier: 'core',
|
||||
description: 'Remove chart tiles from a dashboard.',
|
||||
parameters: params(
|
||||
{
|
||||
id: { type: 'string', description: 'Dashboard id (default active)' },
|
||||
charts: {
|
||||
type: 'string',
|
||||
description: 'Comma/space chart ids to remove',
|
||||
},
|
||||
},
|
||||
['charts']
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'open_dashboard',
|
||||
tier: 'core',
|
||||
description: 'Open the Dashboard tab and select a board by id (or active).',
|
||||
parameters: params({
|
||||
id: { type: 'string', description: 'Dashboard id (optional)' },
|
||||
}),
|
||||
},
|
||||
|
||||
// ── deep: investigation, fleet, storage, catalog ───────────────────
|
||||
{
|
||||
type: 'function',
|
||||
@@ -582,6 +689,13 @@ const LOCAL_TOOLS = new Set([
|
||||
'filter_charts',
|
||||
'show_related_ui',
|
||||
'run_correlations',
|
||||
'list_dashboards',
|
||||
'create_dashboard',
|
||||
'update_dashboard',
|
||||
'delete_dashboard',
|
||||
'add_dashboard_charts',
|
||||
'remove_dashboard_charts',
|
||||
'open_dashboard',
|
||||
'local_knowledge',
|
||||
])
|
||||
|
||||
@@ -646,6 +760,15 @@ function wireDefs(defs) {
|
||||
* runMetricCorrelations?: (opts?: object) => Promise<any>,
|
||||
* scrollToChart?: (id: string) => void,
|
||||
* },
|
||||
* dashboards?: {
|
||||
* list?: () => object,
|
||||
* create?: (a: object) => object,
|
||||
* update?: (a: object) => object,
|
||||
* remove?: (a: object) => object,
|
||||
* addCharts?: (a: object) => object,
|
||||
* removeCharts?: (a: object) => object,
|
||||
* open?: (a: object) => object,
|
||||
* },
|
||||
* confirmAction?: (message: string) => boolean|Promise<boolean>,
|
||||
* }} deps
|
||||
*/
|
||||
@@ -906,6 +1029,62 @@ export function createToolRunner(deps) {
|
||||
deps.onOpenView?.(String(args.view || 'overview'))
|
||||
return { ok: true, view: args.view }
|
||||
}
|
||||
case 'list_dashboards': {
|
||||
if (!deps.dashboards?.list) return { error: 'dashboards API unavailable' }
|
||||
return deps.dashboards.list()
|
||||
}
|
||||
case 'create_dashboard': {
|
||||
if (!deps.dashboards?.create) return { error: 'dashboards API unavailable' }
|
||||
const r = deps.dashboards.create({
|
||||
name: args.name,
|
||||
description: args.description,
|
||||
charts: args.charts || args.chart,
|
||||
mode: args.mode || 'area',
|
||||
tiles: args.tiles,
|
||||
})
|
||||
deps.onOpenView?.('dashboard')
|
||||
return r
|
||||
}
|
||||
case 'update_dashboard': {
|
||||
if (!deps.dashboards?.update) return { error: 'dashboards API unavailable' }
|
||||
return deps.dashboards.update({
|
||||
id: args.id || args.dashboardId,
|
||||
name: args.name,
|
||||
description: args.description,
|
||||
charts: args.charts,
|
||||
mode: args.mode,
|
||||
tiles: args.tiles,
|
||||
})
|
||||
}
|
||||
case 'delete_dashboard': {
|
||||
if (!deps.dashboards?.remove) return { error: 'dashboards API unavailable' }
|
||||
return deps.dashboards.remove({ id: args.id || args.dashboardId })
|
||||
}
|
||||
case 'add_dashboard_charts': {
|
||||
if (!deps.dashboards?.addCharts) return { error: 'dashboards API unavailable' }
|
||||
const r = deps.dashboards.addCharts({
|
||||
id: args.id || args.dashboardId,
|
||||
charts: args.charts || args.chart,
|
||||
mode: args.mode,
|
||||
replace: args.replace,
|
||||
})
|
||||
deps.onOpenView?.('dashboard')
|
||||
return r
|
||||
}
|
||||
case 'remove_dashboard_charts': {
|
||||
if (!deps.dashboards?.removeCharts) return { error: 'dashboards API unavailable' }
|
||||
return deps.dashboards.removeCharts({
|
||||
id: args.id || args.dashboardId,
|
||||
charts: args.charts || args.chart,
|
||||
})
|
||||
}
|
||||
case 'open_dashboard': {
|
||||
if (!deps.dashboards?.open) {
|
||||
deps.onOpenView?.('dashboard')
|
||||
return { ok: true, view: 'dashboard' }
|
||||
}
|
||||
return deps.dashboards.open({ id: args.id || args.dashboardId })
|
||||
}
|
||||
case 'silence_alert': {
|
||||
if (!args.confirmed) {
|
||||
return {
|
||||
|
||||
+199
@@ -3808,6 +3808,205 @@ html[data-theme='light'] .proc-detail-cmd {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* ── Custom Dashboards ─────────────────────────────────────────────── */
|
||||
#dashboard-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 220px) 1fr;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dashboard-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-sidebar {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 12px;
|
||||
min-height: 200px;
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.dashboard-sidebar-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dashboard-list-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-list-item:hover {
|
||||
background: color-mix(in srgb, var(--accent-primary) 8%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-list-item.active {
|
||||
border-color: color-mix(in srgb, var(--accent-primary) 40%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--accent-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-list-empty {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.dashboard-main {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-edit-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.dashboard-edit-fields.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.dashboard-add-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dashboard-add-row select {
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary, var(--bg));
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.dashboard-empty {
|
||||
padding: 28px 20px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed var(--border-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dashboard-empty.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.dashboard-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.dashboard-tile-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dashboard-tile-head h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.dashboard-tile-id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.dashboard-tile-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dashboard-tile-actions.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.dashboard-tile-mode {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 4px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary, var(--bg));
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.dashboard-tile-canvas {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--bg-primary, #0b1020) 85%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-tile-foot {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
#dashboard-view.is-editing .dashboard-tile {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.qvac-model-chip {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
|
||||
Reference in New Issue
Block a user