Files
shopdb-flask/frontend/src/components/pluginAssetPanels.js
benjamin.baker af133372b4 Gate endpoint UI refresh on fresh telemetry and land endpoint agent
A command reaching 'succeeded' only proves the agent ran the action, not that it posted a new telemetry snapshot. Reloading panels on command success alone re-rendered stale service/process rows, so a stopped process could still show running right after Refresh Status.

Add a lightweight telemetrymeta probe exposing lastreportedat and gate both the header Refresh Status action and inline row Start/Stop on that stamp advancing past a pre-action baseline. Polling is bounded so an offline or idle agent never hangs the button, and messaging distinguishes a confirmed refresh from a queued one.

Bring the .NET endpoint agent source (service, tray, installer) into the repo so Cameron can merge and test, with an agent/.gitignore keeping bin/obj/publish out. Includes the DncMain launch-path fix, tray single-file host env scrub, and best-effort file logger.
2026-08-28 15:07:20 -04:00

190 lines
6.8 KiB
JavaScript

// 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: { <rowvalue>: '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
}