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'])