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:
185
frontend/src/components/AssetTriageBand.vue
Normal file
185
frontend/src/components/AssetTriageBand.vue
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="hasAnything" class="triage-band">
|
||||||
|
<div v-if="liveness" class="triage-item">
|
||||||
|
<span class="triage-label">Status</span>
|
||||||
|
<span class="triage-chip" :class="liveness.level">
|
||||||
|
<span class="triage-dot"></span>{{ liveness.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="machines.length" class="triage-item">
|
||||||
|
<span class="triage-label">{{ machines.length === 1 ? 'Serves machine' : 'Serves machines' }}</span>
|
||||||
|
<span class="triage-values">
|
||||||
|
<router-link
|
||||||
|
v-for="machine in machines"
|
||||||
|
:key="machine.assetid"
|
||||||
|
class="triage-chip link"
|
||||||
|
:to="`/assets/${machine.assetid}`"
|
||||||
|
>{{ machine.assetnumber || machine.name }}</router-link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="accessmethods && accessmethods.length" class="triage-item">
|
||||||
|
<span class="triage-label">Remote access</span>
|
||||||
|
<span class="triage-values">
|
||||||
|
<a
|
||||||
|
v-for="method in accessmethods"
|
||||||
|
:key="method.name || method.protocol"
|
||||||
|
class="triage-chip link"
|
||||||
|
:class="{ disabled: !method.link }"
|
||||||
|
:href="method.link || undefined"
|
||||||
|
>{{ method.name || method.protocol }}</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="locationname" class="triage-item">
|
||||||
|
<span class="triage-label">Location</span>
|
||||||
|
<span class="triage-value">{{ locationname }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* The triage band: what somebody needs at 6am with a machine down.
|
||||||
|
*
|
||||||
|
* Core-owned and shared on purpose. The five asset detail pages each rebuild
|
||||||
|
* their own hero and grid, which is how they drifted apart; anything new should
|
||||||
|
* be written once so machines, printers and network devices can adopt it
|
||||||
|
* without a fifth copy.
|
||||||
|
*
|
||||||
|
* "Serves machine" is the reason this exists. On a shop floor a PC matters
|
||||||
|
* because a machine stops without it, and that fact was buried in the generic
|
||||||
|
* relationships card near the bottom of the page.
|
||||||
|
*/
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
assetid: { type: Number, required: true },
|
||||||
|
// Optional because liveness is contributed, not core: only a plugin that
|
||||||
|
// collects telemetry can answer it, and a site may not have one installed.
|
||||||
|
// Shape: { level: 'ok'|'warning'|'critical'|'unknown', label: 'Reporting, 4m ago' }
|
||||||
|
liveness: { type: Object, default: null },
|
||||||
|
accessmethods: { type: Array, default: () => [] },
|
||||||
|
locationname: { type: String, default: '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const machines = ref([])
|
||||||
|
|
||||||
|
const hasAnything = computed(() =>
|
||||||
|
Boolean(props.liveness) ||
|
||||||
|
machines.value.length > 0 ||
|
||||||
|
(props.accessmethods && props.accessmethods.length > 0) ||
|
||||||
|
Boolean(props.locationname)
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(loadMachines)
|
||||||
|
watch(() => props.assetid, loadMachines)
|
||||||
|
|
||||||
|
async function loadMachines() {
|
||||||
|
machines.value = []
|
||||||
|
if (!props.assetid) return
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/assets/${props.assetid}/relationships`)
|
||||||
|
const data = response.data.data || {}
|
||||||
|
const found = []
|
||||||
|
// BOTH directions. The canonical edge is PC controls machine, but
|
||||||
|
// production also holds reciprocal rows, so reading one direction shows
|
||||||
|
// the link on half the pages and not the other half.
|
||||||
|
for (const edge of data.outgoing || []) collect(edge, edge.targetasset, found)
|
||||||
|
for (const edge of data.incoming || []) collect(edge, edge.sourceasset, found)
|
||||||
|
machines.value = found
|
||||||
|
} catch (error) {
|
||||||
|
// A band that cannot load its links simply does not show them. It sits
|
||||||
|
// above the fold on a page whose other cards are fine.
|
||||||
|
machines.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collect(edge, other, found) {
|
||||||
|
if (!other) return
|
||||||
|
if ((edge.relationshiptypename || '').toLowerCase() !== 'controls') return
|
||||||
|
if ((other.assettypename || '').toLowerCase() !== 'machine') return
|
||||||
|
if (found.some(m => m.assetid === other.assetid)) return
|
||||||
|
found.push({ assetid: other.assetid, assetnumber: other.assetnumber, name: other.name })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.triage-band {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.25rem 2rem;
|
||||||
|
padding: 0.9rem 0 0;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.triage-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triage-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.triage-values {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triage-value { font-size: 0.95rem; }
|
||||||
|
|
||||||
|
.triage-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triage-chip.link { border-color: var(--primary); color: var(--primary); }
|
||||||
|
.triage-chip.link:hover { background: var(--bg-card); }
|
||||||
|
|
||||||
|
.triage-chip.disabled {
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text-light);
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triage-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
background: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.triage-chip.ok .triage-dot { background: var(--success); }
|
||||||
|
.triage-chip.warning .triage-dot { background: var(--warning); }
|
||||||
|
.triage-chip.critical .triage-dot { background: var(--danger); }
|
||||||
|
|
||||||
|
.triage-chip.ok { color: var(--success-dark); }
|
||||||
|
.triage-chip.warning { color: var(--warning-dark); }
|
||||||
|
.triage-chip.critical { color: var(--danger); }
|
||||||
|
|
||||||
|
/* Dark mode redefines --success and --warning to bright variants but leaves
|
||||||
|
the -dark ones tuned for a white background, so they read as dark green and
|
||||||
|
dark amber on the dark card. Scoped to the stamped attribute, matching
|
||||||
|
style.css: a bare prefers-color-scheme query leaks into light mode on a
|
||||||
|
dark-OS machine. */
|
||||||
|
[data-theme="dark"] .triage-chip.ok { color: var(--success); }
|
||||||
|
[data-theme="dark"] .triage-chip.warning { color: var(--warning); }
|
||||||
|
</style>
|
||||||
@@ -49,6 +49,16 @@
|
|||||||
<span class="hero-detail-value">{{ computer.locationname }}</span>
|
<span class="hero-detail-value">{{ computer.locationname }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -125,20 +135,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Remote Access -->
|
<!-- Remote Access lives in the triage band at the top of the page
|
||||||
<div class="section-card">
|
now: it is what a responder clicks first, and rendering it twice
|
||||||
<h3 class="section-title">Remote Access</h3>
|
made the page longer without telling anyone anything. -->
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Status / Check-in -->
|
<!-- Status / Check-in, from the COMPUTER RECORD (the asset-report
|
||||||
<div class="section-card">
|
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>
|
<h3 class="section-title">Status</h3>
|
||||||
<div class="info-list">
|
<div class="info-list">
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
@@ -285,6 +294,7 @@ import { withBase } from '@/utils/basePath'
|
|||||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
|
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
|
||||||
|
import AssetTriageBand from '@/components/AssetTriageBand.vue'
|
||||||
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
||||||
import AssetRelationships from '@/components/AssetRelationships.vue'
|
import AssetRelationships from '@/components/AssetRelationships.vue'
|
||||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||||
@@ -301,6 +311,18 @@ const { isEnabled } = useIdentifierFlags()
|
|||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const computer = ref(null)
|
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 installedApps = ref([])
|
||||||
const assetPanels = ref(null)
|
const assetPanels = ref(null)
|
||||||
const restarting = ref(false)
|
const restarting = ref(false)
|
||||||
@@ -317,6 +339,42 @@ const { heroWarranty, warrantyDate: formatWarrantyDate } = useWarrantyBadge(() =
|
|||||||
|
|
||||||
// relationships render via the shared AssetRelationships card
|
// 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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await computersApi.get(route.params.id)
|
const response = await computersApi.get(route.params.id)
|
||||||
@@ -329,6 +387,7 @@ onMounted(async () => {
|
|||||||
installedApps.value = appsResponse.data.data || []
|
installedApps.value = appsResponse.data.data || []
|
||||||
} catch (appError) {
|
} catch (appError) {
|
||||||
}
|
}
|
||||||
|
loadLiveness(computer.value?.assetid)
|
||||||
// Warranties load via useWarrantyBadge (watches computer.assetid).
|
// Warranties load via useWarrantyBadge (watches computer.assetid).
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading computer:', error)
|
console.error('Error loading computer:', error)
|
||||||
|
|||||||
@@ -306,7 +306,13 @@ def _health_for(state, criticalcount):
|
|||||||
@endpoints_bp.route('/<int:asset_id>', methods=['GET'])
|
@endpoints_bp.route('/<int:asset_id>', methods=['GET'])
|
||||||
@require_permission('endpoints.view')
|
@require_permission('endpoints.view')
|
||||||
def get_endpoint(asset_id: int):
|
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()
|
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||||
|
|
||||||
if not state:
|
if not state:
|
||||||
@@ -316,7 +322,18 @@ def get_endpoint(asset_id: int):
|
|||||||
http_code=404,
|
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'])
|
@endpoints_bp.route('/<int:asset_id>/events', methods=['GET'])
|
||||||
|
|||||||
Reference in New Issue
Block a user