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:
benjamin.baker
2026-08-28 15:07:20 -04:00
parent c001657582
commit af133372b4
46 changed files with 4593 additions and 128 deletions

View File

@@ -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`)
}
}

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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({

View File

@@ -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