// 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() } 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 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 } 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 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. export function tabSections(panel) { const data = panel._data const sections = (data && Array.isArray(data.sections)) ? data.sections : [] return sections .map((s) => ({ label: s.label, fields: (s.fields || []).map((f) => ({ label: f.label, value: formatValue(f.value, f), mono: !!f.mono, })), })) .filter((s) => s.fields.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, } } 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 if (panel.render === 'badge') return badges(panel).length > 0 return false }