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/<id> 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.
This commit is contained in:
cproudlock
2026-08-28 22:21:22 -04:00
parent 4c43c4e1ba
commit 0f9ffdb2f1
3 changed files with 276 additions and 15 deletions

View File

@@ -49,6 +49,16 @@
<span class="hero-detail-value">{{ computer.locationname }}</span>
</div>
</div>
<!-- The 6am band: is it alive, what stops without it, how do I reach
it. "Serves machine" used to sit in the relationships card near
the bottom of the page, which is the wrong place for the single
most useful fact about a shop-floor PC. -->
<AssetTriageBand
:assetid="computer.assetid"
:liveness="liveness"
:accessmethods="computer.accessmethods || []"
/>
</div>
</div>
@@ -125,20 +135,19 @@
</div>
</div>
<!-- Remote Access -->
<div class="section-card">
<h3 class="section-title">Remote Access</h3>
<div class="access-methods">
<template v-for="a in (computer.accessmethods || [])" :key="a.id">
<a v-if="a.link" :href="a.link" class="access-link" :title="a.link">{{ a.name }}</a>
<span v-else class="access-link disabled" title="No hostname/IP set for this PC">{{ a.name }}</span>
</template>
<span v-if="!(computer.accessmethods || []).length" class="muted">None configured</span>
</div>
</div>
<!-- Remote Access lives in the triage band at the top of the page
now: it is what a responder clicks first, and rendering it twice
made the page longer without telling anyone anything. -->
<!-- Status / Check-in -->
<div class="section-card">
<!-- Status / Check-in, from the COMPUTER RECORD (the asset-report
collector), which is a different source from the endpoints
agent's live telemetry panel. Shown only when that record holds
something: on a PC with an agent these fields sit empty while
the endpoint panel is fully populated, so an always-on card
reported "Last Reported: Never" next to live telemetry saying
otherwise. Two sources disagreeing in public is worse than one
source missing. -->
<div v-if="hasRecordCheckin" class="section-card">
<h3 class="section-title">Status</h3>
<div class="info-list">
<div class="info-row">
@@ -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)

View File

@@ -306,7 +306,13 @@ def _health_for(state, criticalcount):
@endpoints_bp.route('/<int:asset_id>', 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('/<int:asset_id>/events', methods=['GET'])