Fleet console: triage band, activity feed, live state first

This commit is contained in:
2026-08-28 22:35:16 -04:00
parent 2a94c3c2d2
commit 7e779a6829
6 changed files with 448 additions and 28 deletions

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