Files
shopdb-flask/frontend/src/components/PluginAssetPanels.vue
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

367 lines
15 KiB
Vue

<template>
<!-- Generic renderer for ADR-010 get_asset_panels (Path A). A plugin declares
a panel as JSON (title, endpoint, render mode, field map); this renders it
on any asset detail page, so a plugin adds detail-page UI with no Vue. -->
<template v-for="panel in panels" :key="panel.id">
<div v-if="panelVisible(panel)" class="section-card">
<!-- collapsible panels get a clickable header with a caret and, when the
panel declares a status rollup, a green/yellow/red dot for the worst
row (a stopped service or a warning/critical event). --><h3
v-if="panel.collapsible"
class="section-title pap-header"
role="button"
tabindex="0"
:aria-expanded="!isCollapsed(panel)"
@click="toggleCollapse(panel)"
@keydown.enter.prevent="toggleCollapse(panel)"
@keydown.space.prevent="toggleCollapse(panel)"
>
<span class="pap-caret" :class="{ collapsed: isCollapsed(panel) }" aria-hidden="true">&#9662;</span>
<span class="pap-header-title">{{ panel.title }}</span>
<span
v-if="rollupStatus(panel)"
class="pap-status-dot"
:style="{ backgroundColor: rollupStatus(panel).color }"
:title="rollupStatus(panel).level"
></span>
</h3>
<h3 v-else class="section-title">{{ panel.title }}</h3>
<div v-show="!panel.collapsible || !isCollapsed(panel)" class="pap-body">
<!-- list: items each with a title, optional status badge, meta lines -->
<div v-if="panel.render === 'list'" class="pap-list">
<template v-if="rows(panel).length">
<div v-for="(item, index) in rows(panel)" :key="index" class="pap-item">
<div class="pap-item-top">
<span class="pap-title">{{ mapTitle(panel, item) }}</span>
<span v-if="mapBadge(panel, item)" class="status-badge"
:style="colorStyle(mapBadge(panel, item).color)">
{{ mapBadge(panel, item).label }}
</span>
<button
v-if="rowAction(panel, item)"
type="button"
class="btn btn-sm pap-row-action"
:disabled="actionBusy[panel.id]"
@click="runRowAction(panel, item)"
>{{ rowAction(panel, item).toggleLabel }}</button>
</div>
<div v-if="mapMeta(panel, item).length" class="pap-meta">
<span v-for="(m, mi) in mapMeta(panel, item)" :key="mi"
:class="{ mono: m.mono }">{{ m.text }}</span>
</div>
</div>
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
{{ panel.manage.label || 'Manage' }}
</router-link>
<div v-if="actionError[panel.id]" class="pap-action-error">
{{ actionError[panel.id] }}
</div>
</template>
<div v-else class="pap-empty">
<span class="muted">{{ panel.empty || 'Nothing to show.' }}</span>
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
{{ panel.manage.emptylabel || panel.manage.label || 'Add' }}
</router-link>
</div>
</div>
<!-- tabs: several label/value sections, one visible at a time. Keeps a
panel with many fields to the height of its largest section - a
30-row card cannot be split by the two-column multicol layout and
drags one column far past the other. -->
<div v-else-if="panel.render === 'tabs'" class="pap-tabs">
<div class="pap-tabstrip" role="tablist">
<button
v-for="(section, si) in tabSections(panel)"
:key="si"
type="button"
role="tab"
class="pap-tab"
:class="{ on: si === activeTab(panel) }"
:aria-selected="si === activeTab(panel)"
@click="setTab(panel, si)"
>{{ section.label }}</button>
</div>
<div class="pap-kv">
<div
v-for="(field, fi) in (tabSections(panel)[activeTab(panel)] || {}).fields || []"
:key="fi"
class="pap-kv-row"
>
<span class="pap-kv-label">{{ field.label }}</span>
<span class="pap-kv-value" :class="{ mono: field.mono }">{{ field.value }}</span>
</div>
</div>
</div>
<!-- keyvalue: a label/value grid -->
<div v-else-if="panel.render === 'keyvalue'" class="pap-kv">
<div v-for="(field, fi) in keyvalueFields(panel)" :key="fi" class="pap-kv-row">
<span class="pap-kv-label">{{ field.label }}</span>
<span class="pap-kv-value" :class="{ mono: field.mono }">{{ field.value }}</span>
</div>
</div>
<!-- table: columns declared by the panel (or inferred from row keys) -->
<div v-else-if="panel.render === 'table'" class="pap-table-wrap">
<table class="pap-table">
<thead>
<tr><th v-for="col in tableColumns(panel)" :key="col.key">{{ col.label }}</th></tr>
</thead>
<tbody>
<tr v-for="(row, ri) in rows(panel)" :key="ri">
<td v-for="col in tableColumns(panel)" :key="col.key">{{ cell(row, col) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- badge: a row of colored badges -->
<div v-else-if="panel.render === 'badge'" class="pap-badges">
<span v-for="(b, bi) in badges(panel)" :key="bi" class="status-badge"
:style="colorStyle(b.color)">{{ b.label }}</span>
</div>
</div>
</div>
</template>
</template>
<script setup>
import { ref, watch } from 'vue'
import api from '../api'
import { colorStyle } from '@/utils/colorStyle'
import {
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
rowAction,
} from './pluginAssetPanels'
const props = defineProps({
assetid: { type: [Number, String], default: null },
})
const panels = ref([])
// Which tab each 'tabs' panel is showing, keyed by panel id. Kept here rather
// than on the panel object because panels are replaced wholesale on refetch.
const tabs = ref({})
function activeTab(panel) { return tabs.value[panel.id] || 0 }
function setTab(panel, index) { tabs.value = { ...tabs.value, [panel.id]: index } }
// Collapsed state per panel id, kept here for the same reason as tabs: panels
// are replaced wholesale on refetch. A panel may declare collapsedDefault to
// start collapsed (for example a low-priority events card); once the user
// toggles it, their explicit choice (stored by id) wins.
const collapsed = ref({})
function isCollapsed(panel) {
const state = collapsed.value[panel.id]
if (state === undefined) return !!panel.collapsedDefault
return !!state
}
function toggleCollapse(panel) {
collapsed.value = { ...collapsed.value, [panel.id]: !isCollapsed(panel) }
}
// Per-panel action state: busy guards a double-click while an enqueue POST is
// in flight, and the error string surfaces a rejected enqueue (bad action, or
// a 409 when a command is already queued). Keyed by panel id for the same
// reason as tabs/collapsed: panels are replaced wholesale on refetch.
const actionBusy = ref({})
const actionError = ref({})
// Enqueue the inline row toggle's command (Start/Stop for an actionable
// services/processes row, e.g. MTConnect), then reload so the row reflects the
// new state. The agent long-polls, runs it, posts the result, and wakes an
// immediate telemetry re-collect, so the settled run state lands on the next
// panel refresh rather than after the full cadence.
async function runRowAction(panel, item) {
if (actionBusy.value[panel.id]) return
const info = rowAction(panel, item)
if (!info || !info.endpoint || !info.toggle) return
actionBusy.value = { ...actionBusy.value, [panel.id]: true }
actionError.value = { ...actionError.value, [panel.id]: '' }
try {
const body = { action: info.toggle }
if (info.target) body.target = info.target
// Baseline telemetry freshness before queuing so we can tell a genuinely
// new snapshot from the stale cached one after the command settles.
const baseline = await currentTelemetryStamp()
const response = await api.post(toApiPath(info.endpoint, props.assetid), body)
const commandId = response?.data?.data?.endpointcommandid
// The POST only queues the command (201). The agent runs it asynchronously,
// so poll for the terminal result and surface the agent's own message when
// it fails (e.g. a bad launch path), rather than reporting a false success.
const settled = await waitForCommandResult(commandId)
if (settled && settled.status === 'failed') {
actionError.value = {
...actionError.value,
[panel.id]: settled.resultmessage || 'The endpoint agent could not run the command.',
}
} else if (settled && settled.status === 'succeeded') {
// The command ran, but the fresh run state only lands when the agent's
// follow-up telemetry post arrives. Wait for lastreportedat to advance so
// the reloaded row reflects reality instead of the pre-command snapshot.
await waitForFreshTelemetry(baseline)
}
await load()
} catch (err) {
const message = err?.response?.data?.message || 'Command failed to queue.'
actionError.value = { ...actionError.value, [panel.id]: message }
} finally {
actionBusy.value = { ...actionBusy.value, [panel.id]: false }
}
}
// Poll the endpoint's recent commands until the given command reaches a terminal
// state (succeeded/failed) or the attempt budget runs out. Returns the command
// row or null. Uses the same commands endpoint the operator view reads.
async function waitForCommandResult(commandId, { attempts = 15, delayMs = 1000 } = {}) {
if (!commandId || !props.assetid) return null
for (let i = 0; i < attempts; i += 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
try {
const response = await api.get(`/endpoints/${props.assetid}/commands`)
const command = (response?.data?.data || []).find(
(row) => row.endpointcommandid === commandId)
if (command && (command.status === 'succeeded' || command.status === 'failed')) {
return command
}
} catch {
// Transient poll failure: keep trying until the attempt budget runs out.
}
}
return null
}
// Read the endpoint's current lastreportedat (ISO string or null) so a row
// action can baseline freshness before it forces a state change.
async function currentTelemetryStamp() {
if (!props.assetid) return null
try {
const response = await api.get(`/endpoints/asset/${props.assetid}/telemetrymeta`)
return response?.data?.data?.lastreportedat || null
} catch {
return null
}
}
// Poll lastreportedat until it advances past the baseline (proof the agent has
// posted a fresh snapshot reflecting the command) or the attempt budget runs
// out. Bounded so an idle/offline agent never hangs the row button; the caller
// reloads regardless once this returns.
async function waitForFreshTelemetry(baseline, { attempts = 15, delayMs = 1000 } = {}) {
const baseMs = baseline ? Date.parse(baseline) : 0
for (let i = 0; i < attempts; i += 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
const stamp = await currentTelemetryStamp()
if (stamp && Date.parse(stamp) > baseMs) return true
}
return false
}
// Fetch the declared panels for this asset, then each panel's data endpoint.
async function load() {
panels.value = []
if (!props.assetid) return
let declared
try {
const response = await api.get('/pluginui/asset-panels', {
params: { assetid: props.assetid },
})
declared = response.data.data || []
} catch (err) {
return
}
const withData = await Promise.all(
declared.map(async (panel) => {
try {
const response = await api.get(toApiPath(panel.endpoint, props.assetid))
return { ...panel, _data: response.data.data }
} catch (err) {
return { ...panel, _data: null }
}
})
)
panels.value = withData
}
watch(() => props.assetid, load, { immediate: true })
// Let a parent detail page (for example PCDetail's header Refresh button)
// trigger a panel reload without duplicating the fetch plumbing.
defineExpose({ refresh: load })
</script>
<style scoped>
.pap-list { display: flex; flex-direction: column; gap: 0.75rem; }
.pap-item { padding: 0.6rem 0.75rem; background: var(--bg); border-radius: 6px; }
.pap-item-top { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.pap-title { font-weight: 600; color: var(--text); }
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
.pap-meta { margin-top: 0.35rem; display: flex; flex-wrap: wrap; gap: 0.75rem; font-size: 0.82rem; color: var(--text-light); }
.pap-empty { display: flex; align-items: center; gap: 0.6rem; }
.pap-manage { font-size: 0.82rem; }
/* Two aligned columns rather than space-between: a ragged right edge is hard
to scan once a panel carries more than a handful of rows, and a long value
(a UNC path) otherwise pushes its label away from the others. */
.pap-kv { display: grid; grid-template-columns: minmax(7rem, auto) 1fr; gap: 0.35rem 1rem; align-items: baseline; }
.pap-kv-row { display: contents; }
.pap-kv-label { color: var(--text-light); }
.pap-kv-value { color: var(--text); font-weight: 500; overflow-wrap: anywhere; }
/* Tab strip: a quiet segmented control, not browser-default buttons. The
active tab is marked by an underline AND weight, so it does not rely on
colour alone. */
.pap-tabstrip {
display: flex;
flex-wrap: wrap;
gap: 0.15rem;
margin-bottom: 0.85rem;
border-bottom: 1px solid var(--border);
}
.pap-tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
padding: 0.35rem 0.7rem;
color: var(--text-light);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
cursor: pointer;
white-space: nowrap;
}
.pap-tab:hover { color: var(--text); }
.pap-tab.on {
color: var(--primary);
border-bottom-color: var(--primary);
font-weight: 700;
}
.pap-table-wrap { overflow-x: auto; }
.pap-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.pap-table th, .pap-table td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); }
.pap-badges { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.pap-row-action { margin-left: 0.5rem; flex: 0 0 auto; }
.pap-action-error { color: var(--danger, #f5365c); font-size: 0.82rem; }
.pap-header {
display: flex; align-items: center; gap: 0.5rem;
cursor: pointer; user-select: none;
}
.pap-header:hover .pap-header-title { color: var(--primary); }
.pap-header-title { flex: 1 1 auto; }
.pap-caret {
font-size: 0.7rem; color: var(--text-light);
transition: transform 0.15s ease;
}
.pap-caret.collapsed { transform: rotate(-90deg); }
.pap-status-dot {
flex: 0 0 auto; width: 0.7rem; height: 0.7rem; border-radius: 50%;
}
.mono { font-family: monospace; }
.muted { color: var(--text-light); }
</style>