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.
This commit is contained in:
@@ -153,6 +153,17 @@ export const endpointsApi = {
|
||||
},
|
||||
get(assetId) {
|
||||
return api.get(`/endpoints/${assetId}`)
|
||||
},
|
||||
enqueueCommand(assetId, action, target = null) {
|
||||
const body = { action }
|
||||
if (target) body.target = target
|
||||
return api.post(`/endpoints/${assetId}/commands`, body)
|
||||
},
|
||||
listCommands(assetId) {
|
||||
return api.get(`/endpoints/${assetId}/commands`)
|
||||
},
|
||||
telemetryMeta(assetId) {
|
||||
return api.get(`/endpoints/asset/${assetId}/telemetrymeta`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -150,12 +150,18 @@ 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 (default expanded), kept here for the same
|
||||
// reason as tabs: panels are replaced wholesale on refetch.
|
||||
// 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) { return !!collapsed.value[panel.id] }
|
||||
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]: !collapsed.value[panel.id] }
|
||||
collapsed.value = { ...collapsed.value, [panel.id]: !isCollapsed(panel) }
|
||||
}
|
||||
|
||||
// Per-panel action state: busy guards a double-click while an enqueue POST is
|
||||
@@ -179,7 +185,26 @@ async function runRowAction(panel, item) {
|
||||
try {
|
||||
const body = { action: info.toggle }
|
||||
if (info.target) body.target = info.target
|
||||
await api.post(toApiPath(info.endpoint, props.assetid), body)
|
||||
// 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.'
|
||||
@@ -189,6 +214,53 @@ async function runRowAction(panel, item) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = []
|
||||
@@ -216,6 +288,10 @@ async function load() {
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -29,10 +29,20 @@ export function humanize(key) {
|
||||
|
||||
export function rows(panel) {
|
||||
const data = panel._data
|
||||
if (Array.isArray(data)) return data
|
||||
if (data && Array.isArray(data.rows)) return data.rows
|
||||
if (data && Array.isArray(data.items)) return data.items
|
||||
return []
|
||||
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) {
|
||||
|
||||
@@ -72,6 +72,34 @@ describe('list render mapping (warranty)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('list row filtering (tracked services vs processes)', () => {
|
||||
const feedPanel = {
|
||||
id: 'endpoints-tracked-services', render: 'list',
|
||||
filter: { key: 'kind', equals: 'service' },
|
||||
map: { title: 'name' },
|
||||
_data: [
|
||||
{ name: 'UDC MTConnect Agent', kind: 'service' },
|
||||
{ name: 'DncMain', kind: 'process' },
|
||||
{ name: 'Spooler', kind: 'SERVICE' },
|
||||
],
|
||||
}
|
||||
|
||||
it('keeps only rows whose filter key matches, case-insensitively', () => {
|
||||
const kept = rows(feedPanel).map((r) => r.name)
|
||||
expect(kept).toEqual(['UDC MTConnect Agent', 'Spooler'])
|
||||
})
|
||||
|
||||
it('filters the same feed to processes when equals is process', () => {
|
||||
const kept = rows({ ...feedPanel, filter: { key: 'kind', equals: 'process' } })
|
||||
expect(kept.map((r) => r.name)).toEqual(['DncMain'])
|
||||
})
|
||||
|
||||
it('returns every row when no filter is declared', () => {
|
||||
const kept = rows({ ...feedPanel, filter: undefined })
|
||||
expect(kept).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('keyvalue + table + badge', () => {
|
||||
it('keyvalue reads a fields array or a plain object', () => {
|
||||
const fromFields = keyvalueFields({
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
<input
|
||||
v-model="azureDeviceCategory"
|
||||
type="text"
|
||||
placeholder="e.g. SFLD_DYN_Device_Group_Wilmington_BUS_NC_COUNTRY:USA"
|
||||
placeholder="e.g. SFLD_DYN_Device_Group_<Site>_BUS_NC_COUNTRY:USA"
|
||||
/>
|
||||
<small class="muted">
|
||||
The Entra group/category these devices belong to. Stored on every
|
||||
|
||||
Reference in New Issue
Block a user