From 0f9ffdb2f14d73598065ae6f9755f040ce33be3b Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 28 Aug 2026 22:21:22 -0400 Subject: [PATCH] Put the 6am answers at the top of the PC page, and stop saying things twice Adds a shared triage band to the PC detail hero: is it alive, what stops without it, how do I reach it. 'Serves machine' is the reason it exists. On a shop floor a PC matters because a machine stops without it, and that fact was buried in the generic relationships card at the bottom of the page. Both edge directions are read, deduplicated, and filtered to assets typed 'machine' - production holds reciprocal rows, so reading one direction shows the link on half the pages and not the other half. The band is a CORE component, not a PCDetail block. Five asset detail pages already rebuild the same hero and grid independently, which is how they drifted apart; machines, printers and network devices can drop this in rather than growing a sixth copy. Liveness is passed in rather than fetched, because only a plugin that collects telemetry can answer it and a site may not have one. The status chip's verdict comes from the SERVER: /api/endpoints/ now returns the same reporting/quiet/never it computes for the fleet list. The quiet window is a site setting, so a threshold guessed in the browser would disagree with the Fleet list about the same endpoint the moment a site changed it. Two cards removed from the body: - Remote Access duplicated the band's chips. It is what a responder clicks first, so it belongs above the fold, once. - Status was worse than duplication. It reads the COMPUTER RECORD, while the endpoint panel beside it reads live agent telemetry, and on an agent-managed PC the record fields sit empty - so the page rendered 'Last Reported: Never' a few inches from live telemetry saying otherwise. It now renders only when that record actually holds something, which is the case on sites running the asset-report script instead of the agent. Verified against dev telemetry: band shows 'Reporting, 1 h ago', the machine it serves, and its access chips; both duplicate cards are gone. vitest 260 passed, naming/contract/parity gates green. --- frontend/src/components/AssetTriageBand.vue | 185 ++++++++++++++++++ plugins/computers/frontend/views/PCDetail.vue | 85 ++++++-- plugins/endpoints/api/routes.py | 21 +- 3 files changed, 276 insertions(+), 15 deletions(-) create mode 100644 frontend/src/components/AssetTriageBand.vue diff --git a/frontend/src/components/AssetTriageBand.vue b/frontend/src/components/AssetTriageBand.vue new file mode 100644 index 00000000..1e85040e --- /dev/null +++ b/frontend/src/components/AssetTriageBand.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/plugins/computers/frontend/views/PCDetail.vue b/plugins/computers/frontend/views/PCDetail.vue index 04efec4d..198c1382 100644 --- a/plugins/computers/frontend/views/PCDetail.vue +++ b/plugins/computers/frontend/views/PCDetail.vue @@ -49,6 +49,16 @@ {{ computer.locationname }} + + + @@ -125,20 +135,19 @@ - -
-

Remote Access

-
- - None configured -
-
+ - -
+ +

Status

@@ -285,6 +294,7 @@ import { withBase } from '@/utils/basePath' import { useWarrantyBadge } from '@/composables/warrantyBadge' import LocationMapTooltip from '@/components/LocationMapTooltip.vue' import CustomFieldsSection from '@/components/CustomFieldsSection.vue' +import AssetTriageBand from '@/components/AssetTriageBand.vue' import PluginAssetPanels from '@/components/PluginAssetPanels.vue' import AssetRelationships from '@/components/AssetRelationships.vue' import { useIdentifierFlags } from '@/composables/identifierSettings' @@ -301,6 +311,18 @@ const { isEnabled } = useIdentifierFlags() const loading = ref(true) const computer = ref(null) +// Liveness for the triage band. Null when the endpoints plugin is absent, or +// when the viewer lacks endpoints.view - in either case the chip is simply not +// shown, which is better than rendering "unknown" and implying a fault. +const liveness = ref(null) + +// The computer record's own check-in fields, as opposed to live agent +// telemetry. Empty on an agent-managed PC, populated where the asset-report +// script runs instead. +const hasRecordCheckin = computed(() => { + const record = computer.value?.computer || {} + return Boolean(record.loggedinuser || record.lastreporteddate || record.lastboottime) +}) const installedApps = ref([]) const assetPanels = ref(null) const restarting = ref(false) @@ -317,6 +339,42 @@ const { heroWarranty, warrantyDate: formatWarrantyDate } = useWarrantyBadge(() = // relationships render via the shared AssetRelationships card +async function loadLiveness(assetid) { + if (!assetid) return + try { + const response = await endpointsApi.get(assetid) + const state = response.data.data || {} + // The SERVER decides reporting/quiet/never, because the quiet window is a + // site setting. Guessing a threshold here would disagree with the Fleet + // list about the same endpoint. + const labels = { + reporting: `Reporting, ${relativeAge(state.lastreportedat)}`, + quiet: `Quiet, last seen ${relativeAge(state.lastreportedat)}`, + never: 'Never reported' + } + const levels = { reporting: 'ok', quiet: 'warning', never: 'unknown' } + if (state.status) { + liveness.value = { level: levels[state.status], label: labels[state.status] } + } + } catch (error) { + // 404 = no agent on this PC, 403 = the viewer cannot read telemetry. + // Neither is a page error; the chip just does not appear. + liveness.value = null + } +} + +function relativeAge(value) { + if (!value) return 'never' + const then = new Date(value) + if (isNaN(then)) return 'unknown' + const mins = Math.floor((Date.now() - then.getTime()) / 60000) + if (mins < 1) return 'just now' + if (mins < 60) return `${mins} m ago` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours} h ago` + return `${Math.floor(hours / 24)} d ago` +} + onMounted(async () => { try { const response = await computersApi.get(route.params.id) @@ -329,6 +387,7 @@ onMounted(async () => { installedApps.value = appsResponse.data.data || [] } catch (appError) { } + loadLiveness(computer.value?.assetid) // Warranties load via useWarrantyBadge (watches computer.assetid). } catch (error) { console.error('Error loading computer:', error) diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index 68c8b69a..7f138402 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -306,7 +306,13 @@ def _health_for(state, criticalcount): @endpoints_bp.route('/', methods=['GET']) @require_permission('endpoints.view') def get_endpoint(asset_id: int): - """Get the current state for one endpoint by asset id.""" + """Get the current state for one endpoint by asset id. + + Carries the same reporting/quiet/never verdict the fleet list uses. The + quiet window is a site setting, so the SERVER decides it: a caller that + guessed a threshold in the browser would disagree with the fleet list on + the same endpoint the moment a site changed the setting. + """ state = EndpointState.query.filter_by(assetid=asset_id).first() if not state: @@ -316,7 +322,18 @@ def get_endpoint(asset_id: int): http_code=404, ) - return success_response(state.to_dict()) + cutoff = _naive_utcnow() - timedelta(hours=_quiet_hours()) + if state.lastreportedat is None: + status = 'never' + elif state.lastreportedat < cutoff: + status = 'quiet' + else: + status = 'reporting' + + data = state.to_dict() + data['status'] = status + data['uptimedisplay'] = _format_uptime(state.uptimeseconds) + return success_response(data) @endpoints_bp.route('//events', methods=['GET'])