// Pure render helpers for PluginAssetPanels.vue (ADR-010 get_asset_panels, Path // A). Kept separate from the .vue so the mapping logic - which turns a plugin's // JSON panel declaration + its endpoint data into rendered rows - is unit // tested directly (same pattern as geenforce/entryForm.js). export function subst(text, assetid) { return String(text).replace('{assetid}', assetid) } // Panel endpoints are declared absolute (/api/...); the api instance already // carries the /api base, so strip it before fetching through the instance. export function toApiPath(endpoint, assetid) { return subst(endpoint, assetid).replace(/^\/api(?=\/)/, '') } export function formatValue(value, spec = {}) { if (value === null || value === undefined || value === '') return '' if (spec.format === 'date') { const raw = String(value) return new Date(raw + (raw.length === 10 ? 'T00:00:00' : '')).toLocaleDateString() } // Timestamps arrive as UTC ISO ("2026-09-04T16:13:29Z") because the wire // format is UTC by contract. Render in the viewer's local zone: an operator // comparing "last reported" against the clock on the wall cannot do the // offset in their head, and the Z suffix invites reading it as local. if (spec.format === 'datetime') { const parsed = new Date(String(value)) if (isNaN(parsed)) return value return parsed.toLocaleString() } if (typeof value === 'boolean') return value ? 'Yes' : 'No' return value } export function humanize(key) { return key.charAt(0).toUpperCase() + key.slice(1) } export function rows(panel) { const data = panel._data let list if (Array.isArray(data)) list = data else if (data && Array.isArray(data.rows)) list = data.rows else if (data && Array.isArray(data.items)) list = data.items else if (data && Array.isArray(data.reports)) list = data.reports else return [] // Optional panel-level row filter so two panels can share one endpoint feed // and each show a slice of it (for example Tracked Services vs Tracked // Processes off the same services snapshot). Absent filter = all rows. const filter = panel.filter if (filter && filter.key) { const want = String(filter.equals).toLowerCase() return list.filter((item) => String(item[filter.key]).toLowerCase() === want) } return list } // Optional in-card filter chips. A list panel declares facets: {key, order, // labels} and the renderer offers one chip per distinct value of that key, // with a count. Filtering is client-side over rows the panel already fetched - // no extra request, and the counts stay honest because they are computed from // the same array. // // Declared rather than hardcoded because the reason it exists is generic: the // activity feed mixes info, warning and critical, and somebody triaging wants // the criticals alone. Any list panel with a category column has that problem. // Colour names carry no meaning to a reader. A panel keyed on statecolor // (green/yellow/red) is labelled by the LEVEL those map to, while one keyed on // a word (info/warning/critical) is labelled by the word itself. const COLOUR_VALUES = ['green', 'yellow', 'red', 'grey', 'gray', 'blue'] const LEVEL_ORDER = ['critical', 'warning', 'ok', 'unknown'] // A panel that shows a health dot has already declared what it is dotting on. // Reuse that instead of asking every panel to declare filters a second time: // any card with a status rollup gets chips, and the two cannot disagree. function facetSpec(panel) { if (panel.facets && panel.facets.key) return panel.facets const status = panel.status if (!status || !status.key) return null const levels = status.levels || {} const order = Object.keys(levels).sort( (a, b) => LEVEL_ORDER.indexOf(levels[a]) - LEVEL_ORDER.indexOf(levels[b]) ) const labels = {} Object.keys(levels).forEach((value) => { labels[value] = COLOUR_VALUES.includes(String(value).toLowerCase()) ? humanize(levels[value]) : humanize(value) }) return { key: status.key, order, labels } } export function facets(panel) { const spec = facetSpec(panel) if (!spec || !spec.key) return [] const all = rows(panel) const counts = new Map() all.forEach((item) => { const value = item[spec.key] if (value === null || value === undefined || value === '') return const key = String(value) counts.set(key, (counts.get(key) || 0) + 1) }) // Declared order first, so severity reads critical-warning-info rather than // alphabetically; anything unexpected in the data still gets a chip. const declared = (spec.order || []).filter((value) => counts.has(value)) const extra = [...counts.keys()].filter((value) => !declared.includes(value)).sort() return [...declared, ...extra].map((value) => ({ value, label: (spec.labels && spec.labels[value]) || humanize(value), count: counts.get(value) })) } // rows() after the viewer's chip selection. An empty selection means "no filter // applied", NOT "hide everything": a card that vanished when you deselected the // last chip would read as broken. export function facetedRows(panel, selected) { const all = rows(panel) const spec = facetSpec(panel) if (!spec || !spec.key || !selected || !selected.length) return all return all.filter((item) => selected.includes(String(item[spec.key]))) } export function mapTitle(panel, item) { const key = panel.map && panel.map.title return key ? item[key] : '' } export function mapBadge(panel, item) { const badge = panel.map && panel.map.badge if (!badge) return null const raw = item[badge.label] const label = (badge.labelmap && badge.labelmap[raw]) || raw return { label, color: badge.color ? item[badge.color] : undefined } } export function mapMeta(panel, item) { const meta = (panel.map && panel.map.meta) || [] return meta .map((spec) => { const value = formatValue(item[spec.key], spec) if (value === '') return null const text = spec.label ? `${spec.label} ${value}` : value return { text, mono: !!spec.mono } }) .filter(Boolean) } export function mapBody(panel, item) { const key = panel.map && panel.map.body if (!key) return null const value = item[key] if (value === null || value === undefined || value === '') return null return String(value) } export function manageLink(panel, assetid) { return panel.manage ? subst(panel.manage.to, assetid) : null } export function keyvalueFields(panel) { const data = panel._data if (data && Array.isArray(data.fields)) { return data.fields.map((f) => ({ label: f.label, value: formatValue(f.value, f), mono: !!f.mono, })) } if (data && typeof data === 'object' && !Array.isArray(data)) { return Object.entries(data).map(([k, v]) => ({ label: humanize(k), value: formatValue(v), })) } return [] } export function tableColumns(panel) { if (Array.isArray(panel.columns) && panel.columns.length) { return panel.columns.map((c) => ({ key: c.key, label: c.label || humanize(c.key) })) } const first = rows(panel)[0] if (!first) return [] return Object.keys(first).map((k) => ({ key: k, label: humanize(k) })) } export function cell(row, col) { return formatValue(row[col.key], col) } export function badges(panel) { const data = panel._data const list = Array.isArray(data) ? data : (data && data.badges) || [] return list.map((b) => ({ label: b.label, color: b.color })) } // Header status rollup for a card. A panel opts in with // status: { key, levels: { : 'critical'|'warning'|'ok'|'unknown' } }. // The card header then shows a single green/yellow/red dot reflecting the worst // row: any critical -> red, else any warning -> yellow, else ok -> green. A // value not in levels is treated as unknown and does not raise the level. export const STATUS_RANK = { ok: 1, unknown: 1, warning: 2, critical: 3 } export const STATUS_COLOR = { ok: '#2dce89', warning: '#ffc107', critical: '#f5365c', unknown: '#6c757d', } export function rollupStatus(panel) { const status = panel && panel.status if (!status || !status.key) return null const items = rows(panel) if (!items.length) return null const levels = status.levels || {} let worst = 'ok' let sawKnown = false for (const item of items) { const level = levels[item[status.key]] if (!level) continue sawKnown = true if (STATUS_RANK[level] > STATUS_RANK[worst]) worst = level } if (!sawKnown) return null return { level: worst, color: STATUS_COLOR[worst] || STATUS_COLOR.unknown } } // tabs: [{ label, fields: [{label, value, mono}] }]. One section is shown at a // time, so a panel with 30 values stays the height of its largest section // instead of dragging one multicol column far past the other. // // A section may instead carry { columns, rows, empty } and render as a table. // This exists so two tables that belong side by side for the reader but share // NO key can sit in one card - Storage is per volume, Disk health is per // physical device. The sections are independent tables by design: nothing here // reconciles rows across them, unlike the mapped-drive merge where two sources // described the same drive. export function tabSections(panel) { const data = panel._data const sections = (data && Array.isArray(data.sections)) ? data.sections : [] return sections .map((s) => { if (Array.isArray(s.columns) && s.columns.length) { return { label: s.label, columns: s.columns.map((c) => ({ key: c.key, label: c.label || humanize(c.key) })), rows: Array.isArray(s.rows) ? s.rows : [], empty: s.empty || '', fields: [], } } return { label: s.label, fields: (s.fields || []).map((f) => ({ label: f.label, value: formatValue(f.value, f), mono: !!f.mono, })), } }) // A table section declares its columns up front, so it survives having no // rows and shows its own empty message. Dropping it would take the tab with // it and read as "this machine has no disks" rather than "nothing reported". .filter((s) => s.fields.length > 0 || (s.columns && s.columns.length > 0)) } // Inline row action for a list panel. A monitored services/processes row may // carry action: { running, start, stop, status } when it has a registered // command (e.g. MTConnect). The card renders a single toggle on that row: when // running it offers Stop, otherwise Start. Rows with no action are badge-only. // commandendpoint on the panel is where the enqueue POST goes. export function rowAction(panel, item) { const action = item && item.action if (!action || (!action.start && !action.stop)) return null const running = action.running === true return { running, // The action name to enqueue for the primary toggle button. toggle: running ? action.stop : action.start, toggleLabel: running ? 'Stop' : 'Start', // status is a non-mutating probe, always available when declared. status: action.status || null, // Generic actions (service_*/process_*) carry the subject to act on; fixed // actions (mtconnect_*) leave this null. target: action.target || null, // The declared enqueue endpoint template; the caller substitutes assetid. endpoint: panel.commandendpoint || null, } } // A table needs the full card width. `table` panels get it via panel.render, // but a `tabs` panel only needs it when one of its sections is a table, and // that is not knowable from the declaration - it depends on fetched data. export function spansFullWidth(panel) { if (!panel) return false if (panel.render === 'table') return true if (panel.render !== 'tabs') return false return tabSections(panel).some((s) => (s.columns || []).length > 0) } // A panel declaring `empty` is asking to stay visible with nothing in it and // say so - "No installed software reported." is an answer, where an absent // card is a question. `list` honoured that and `table` did not, for no reason // anybody wrote down, so a table panel with an empty message still vanished. // It matters more now that a panel can be the only thing in a tab: the tab // itself disappears with it. export function panelVisible(panel) { if (panel.render === 'tabs') return tabSections(panel).length > 0 if (panel.render === 'list') return rows(panel).length > 0 || !!panel.empty if (panel.render === 'keyvalue') return keyvalueFields(panel).length > 0 if (panel.render === 'table') return rows(panel).length > 0 || !!panel.empty if (panel.render === 'badge') return badges(panel).length > 0 if (panel.render === 'operatorreports') return rows(panel).length > 0 || !!panel.empty return false }