Files
shopdb-flask/plugins/computers/frontend/views/PCDetail.vue
benjamin.baker ba38e4d39d PC Detail: flow Details cards, drop the IP Configuration card
Details tab card layout
-----------------------
The Details tab sat on .content-grid, a two-column CSS grid. Grid packs
items into implicit rows, so every row is as tall as its tallest card and
align-items:start leaves the shorter one with dead space beneath it:
Warranty is short, Endpoint Status is tall, and Policy had to wait for the
taller of the two rather than sliding up. That read as a staggered layout.

Switched that one tab to multicol via a .details-flow modifier, which has no
rows at all - each card starts where the previous one ended, so the holes
close while cards still size to their content.

Multicol is deliberately NOT applied to .content-grid globally. The note by
that rule in style.css records why it was abandoned there: a column-span:all
child (.pap-span, used by table panels) overlaps the shorter column when the
two are unequal. That cannot happen on this tab - every panel in the details
group is a keyvalue or list render, and spansFullWidth() only returns true
for table (or a tabs panel holding one), so nothing in the subtree spans.
Monitors and Service/Process DO carry table panels and stay on grid.

The modifier is scoped to PCDetail, so the nine other detail views on
.content-grid are untouched.

Chosen over JS masonry on purpose. Masonry packs perfectly but has to
measure every card and re-measure on resize, on panel collapse and on every
async panel load - and these panels fetch after paint, so it would mean
continuous forced reflow on a page that already polls. This is pure CSS:
no measurement, no observer, no runtime cost. The tradeoff accepted is that
column bottoms can end at slightly different heights.

Cards carry break-inside: avoid so none fragments across the column break,
and the 25px spacing returns as a margin because multicol has no gap between
stacked items the way grid does.

IP Configuration card
---------------------
Removed: the data has no purpose on the page anymore. Taken out at the root
rather than hidden - the panel declaration, the /asset/<id>/ipconfig route
and the _IPCONFIG_COLUMNS constant all go, since a route with no consumer is
dead surface that still answers requests.

test_the_two_cards_do_not_duplicate_columns_beyond_the_address existed only
to police the history/config column split; with one card gone it guards
nothing, so it and its import go with it. The panel-count assertion in
test_endpoints_asset_panels_are_computer_scoped moves 15 -> 14, which is the
test doing its job and catching the removal.

IP History is unchanged and still carries the address list.

Not addressed here: three tzset() tests in test_endpoints_plugin.py fail on
Windows because time.tzset() is Unix-only. Verified pre-existing against
unmodified code; they pass in the Linux container.
2026-09-05 15:27:50 -04:00

1186 lines
49 KiB
Vue

<template>
<div class="detail-page">
<div class="page-header">
<h2>Computer Details</h2>
<!-- Record actions only. The two that act on the MACHINE - refresh and
restart - live in the triage band beside the state they change and
the machine they would take down with it. Restart sitting between
Print Label and Edit made the most consequential button on the page
look like a peer of printing a sticker. -->
<div class="header-actions">
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
<router-link :to="`/pcs/${$route.params.id}/edit${fromQuery}`" class="btn btn-primary">Edit</router-link>
<button v-if="!isRetired" @click="confirmRetire" class="btn btn-danger">Retire</button>
<!-- A retired record offered Edit and Retire exactly like a live
one, and nothing said it was retired. Restoring it was possible
through the API and nowhere in the product. -->
<button v-if="isRetired" @click="restore" class="btn btn-primary">Restore</button>
<router-link :to="returnTo" class="btn btn-secondary">{{ returnLabel }}</router-link>
</div>
</div>
<div v-if="computer && computer.isactive === false" class="retired-banner card">
<strong>This PC is retired.</strong>
It stays out of every list and report, and its history and relationships
are intact. Restore puts it back.
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="computer">
<!-- Hero Section -->
<div class="hero-card">
<div class="hero-image" v-if="computer.computer?.imageurl">
<img :src="withBase(computer.computer.imageurl)" :alt="computer.computer.modelname || 'Model photo'" />
</div>
<div class="hero-content">
<!-- Identity, then the cloud row beneath it. An earlier cut put the
portal block in a fixed-width rail on the right; GUIDs are too
long for a narrow column and it wrapped into a tall ragged
stack. Full width in the normal flow costs one short row. -->
<div class="hero-primary">
<div class="hero-headline">
<div class="hero-identity">
<div class="hero-title">
<h1>{{ computer.assetnumber }}</h1>
<span v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
</div>
<div class="hero-meta">
<span class="badge badge-lg badge-info">Computer</span>
<span class="badge badge-lg" :style="colorStyle(computer.statuscolor)">
{{ computer.statusname || 'Unknown' }}
</span>
<span v-if="heroWarranty" class="badge badge-lg" :style="colorStyle(heroWarranty.statuscolor)"
:title="heroWarranty.enddate ? `Warranty ends ${formatWarrantyDate(heroWarranty.enddate)}` : 'Warranty'">
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ formatWarrantyDate(heroWarranty.enddate) }}</template>
</span>
</div>
<div class="hero-details">
<div class="hero-detail" v-if="computer.computer?.computertypename">
<span class="hero-detail-label">PC Type</span>
<span class="hero-detail-value">{{ computer.computer.computertypename }}</span>
</div>
<div class="hero-detail" v-if="computer.computer?.osname">
<span class="hero-detail-label">OS</span>
<span class="hero-detail-value">{{ computer.computer.osname }}</span>
</div>
<div class="hero-detail" v-if="computer.locationname">
<span class="hero-detail-label">Location</span>
<span class="hero-detail-value">{{ computer.locationname }}</span>
</div>
</div>
</div>
<!-- Liveness and the endpoint actions, pulled out of the triage
band into the header corner. They are the two things looked at
first on a PC that is misbehaving, and at the bottom of the
hero they sat below the fold on a laptop. The band keeps the
reference facts (serves machine, remote access, location). -->
<div class="hero-endpoint">
<div v-if="liveness" class="hero-status">
<span class="hero-detail-label hero-status-label">Status</span>
<span class="triage-chip" :class="liveness.level">
<span class="triage-dot"></span>{{ liveness.label }}
</span>
</div>
<div v-if="canCommand" class="hero-endpoint-actions">
<button @click="refreshStatus" class="btn btn-secondary btn-sm"
:disabled="restarting || refreshing">
{{ refreshing ? 'Refreshing...' : 'Refresh Status' }}
</button>
</div>
</div>
</div>
<!-- Cloud identity. Laid out as a horizontal row in the same
label-over-value language as hero-details, not as a narrow
right rail: a 36-character GUID cannot wrap sanely into a
300px column, and the ragged stack that produced grew taller
than the identity block beside it.
Held back until the portal templates resolve so the row does
not paint once without its buttons and then again with them. -->
<div v-if="hasCloudIdentity" class="hero-portals">
<!-- The link sits beside the id it is built from, not in a row
of its own: the pairing is counterintuitive - the DEVICE id
opens Intune and the OBJECT id opens Entra - and a separate
action row would have to re-label which is which. The full
id stays selectable because it gets copied into tickets. -->
<div class="hero-portal">
<span class="hero-portal-label">
Device ID
<a
v-if="intuneDeviceLink"
:href="intuneDeviceLink"
target="_blank"
rel="noopener noreferrer"
class="portal-link"
>Intune</a>
</span>
<span v-if="computer.computer?.azuredeviceid" class="hero-portal-idrow">
<span class="hero-portal-id mono">{{ computer.computer.azuredeviceid }}</span>
<button
type="button"
class="copy-btn"
title="Copy Device ID"
aria-label="Copy Device ID"
@click="copyId(computer.computer.azuredeviceid, 'Device ID')"
><Copy :size="14" aria-hidden="true" /></button>
</span>
<span v-else class="hero-portal-missing">Please provide DeviceID</span>
</div>
<div class="hero-portal">
<span class="hero-portal-label">
Object ID
<a
v-if="entraDeviceLink"
:href="entraDeviceLink"
target="_blank"
rel="noopener noreferrer"
class="portal-link"
>Entra</a>
<a
v-if="lapsDeviceLink"
:href="lapsDeviceLink"
target="_blank"
rel="noopener noreferrer"
class="portal-link"
title="Local administrator password (LAPS)"
>Azure LAPS</a>
</span>
<span v-if="computer.computer?.azureobjectid" class="hero-portal-idrow">
<span class="hero-portal-id mono">{{ computer.computer.azureobjectid }}</span>
<button
type="button"
class="copy-btn"
title="Copy Object ID"
aria-label="Copy Object ID"
@click="copyId(computer.computer.azureobjectid, 'Object ID')"
><Copy :size="14" aria-hidden="true" /></button>
</span>
<span v-else class="hero-portal-missing">Please provide ObjectID</span>
</div>
<div class="hero-portal" v-if="computer.computer?.azuredevicecategory">
<span class="hero-portal-label">Category</span>
<span class="hero-portal-value">{{ computer.computer.azuredevicecategory }}</span>
</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. -->
<!-- Liveness and Refresh Status moved to .hero-endpoint in the header
corner. Restart stays here: it is destructive and takes the
machine down with it, so it belongs beside the "serves machine"
fact that says what stops, not next to a read-only refresh. -->
<AssetTriageBand
:assetid="computer.assetid"
:accessmethods="computer.accessmethods || []"
>
<template #actions v-if="canRestart">
<button @click="confirmRestart" class="btn btn-warning btn-sm"
:disabled="restarting">Restart Computer</button>
</template>
</AssetTriageBand>
</div>
</div>
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
<!-- Main Content Grid -->
<!-- Live state before reference data. The grid is a two-column FLOW
(column-count, with display:contents on the column wrappers), so a
card cannot be pinned to a side - only document order decides where
it lands. Putting the contributed panels first means endpoint status,
services and activity are what a responder meets, and Identity,
Hardware and the rest follow. Panels self-order among themselves by
their declared position, and self-hide when a site has no plugin
answering for this asset type. -->
<AssetDetailTabs v-model="activegroup" :tabs="detailtabs" label="Computer detail sections" />
<!-- Network interfaces is pinned to the top of Details rather than left
in System Monitors: which adapter is up and what address it holds is
identity, asked at the same moment as "what is this box". It still
BELONGS to monitors (declared there, and excluded from that tab
below), so there is one declaration and no new group. Full width and
outside content-grid because the table has eight columns and would
be unreadable in a half-width column. -->
<div v-show="activegroup === 'details'" class="detail-full">
<PluginAssetPanels
:assetid="computer.assetid"
:only="['endpoints-interfaces']"
/>
</div>
<div v-show="activegroup === 'details'" class="content-grid details-flow">
<PluginAssetPanels
ref="assetPanels"
:assetid="computer.assetid"
group="details"
/>
<!-- Mapped drives is lifted the same way as network interfaces: a
missing share is a "why can't this operator open their program"
question, which gets asked on Details, not under monitors. It
stays declared in monitors and is excluded from that tab. -->
<PluginAssetPanels
:assetid="computer.assetid"
:only="['endpoints-mappeddrives']"
/>
<!-- Left Column -->
<div class="content-column">
<!-- Identity Section -->
<div class="section-card">
<h3 class="section-title">Identity</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Asset Number</span>
<span class="info-value">{{ computer.assetnumber }}</span>
</div>
<div class="info-row" v-if="computer.name">
<span class="info-label">Name</span>
<span class="info-value">{{ computer.name }}</span>
</div>
<div class="info-row" v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname">
<span class="info-label">Hostname</span>
<span class="info-value mono">{{ computer.computer.hostname }}</span>
</div>
<div class="info-row" v-if="computer.serialnumber">
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ computer.serialnumber }}</span>
</div>
<div class="info-row" v-if="isEnabled('gaugelabreference', 'computer') && computer.gaugelabreference">
<span class="info-label">Gauge Lab Reference</span>
<span class="info-value mono">{{ computer.gaugelabreference }}</span>
</div>
<div class="info-row" v-if="isEnabled('maintenancereference', 'computer') && computer.maintenancereference">
<span class="info-label">Maintenance Reference</span>
<span class="info-value mono">{{ computer.maintenancereference }}</span>
</div>
</div>
</div>
<!-- Hardware Section -->
<div class="section-card">
<h3 class="section-title">Hardware</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Computer Type</span>
<span class="info-value">{{ computer.computer?.computertypename || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">
{{ computer.computer?.vendorname || '-' }}
<!-- Inherited from the catalog model, not stored on this record. -->
<small v-if="computer.computer?.vendorfrommodel" class="text-muted">(from model)</small>
</span>
</div>
<div class="info-row">
<span class="info-label">Model</span>
<span class="info-value">{{ computer.computer?.modelname || '-' }}</span>
</div>
<!-- Only when it DIFFERS from this asset's own type. The two are
separate tables - modeltypes spans every kind of asset - but
they carry the same names in practice, so showing both
always put two identical Type rows in one section. Worth
seeing only when they disagree. -->
<div class="info-row"
v-if="computer.computer?.modeltypename && computer.computer.modeltypename !== computer.computer?.computertypename">
<span class="info-label">Model type</span>
<span class="info-value">{{ computer.computer.modeltypename }}</span>
</div>
<div class="info-row">
<span class="info-label">Operating System</span>
<span class="info-value">{{ computer.computer?.osname || '-' }}</span>
</div>
</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. -->
<!-- The record-checkin Status card lived here (Logged In User, Last
Reported, Last Boot), sourced from the asset-report collector
rather than the agent. It restated what the Endpoint status panel
already shows from live telemetry, and the two sources phrase the
same facts differently, so the page appeared to disagree with
itself. The panel is the better source: it is populated by the
agent and carries uptime, versions and drift alongside. -->
<!-- The Entra Device Info card lived here. Its links moved to the
header's right-hand block, which was dead space at every width,
so they now cost no vertical room and sit beside the identity
they belong to rather than partway down the page. -->
</div>
<!-- Right Column -->
<div class="content-column">
<!-- The hand-written Network card lived here. It listed current
addresses with a Primary badge and nothing else, which undersold
rows that also carry subnet, gateway, DNS, and their own
first-seen/last-changed stamps. Split into the IP History and IP
Configuration panels under System Monitors, which read the same
Communication rows. -->
<!-- Location & Organization -->
<div class="section-card">
<h3 class="section-title">Location & Organization</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Location</span>
<span class="info-value">
<LocationMapTooltip
v-if="computer.mapx != null && computer.mapy != null"
:left="computer.mapx"
:top="computer.mapy"
:levelid="computer.levelid"
:machineName="computer.assetnumber"
>
<span class="location-link">{{ computer.locationname || 'On Map' }}</span>
</LocationMapTooltip>
<span v-else>{{ computer.locationname || '-' }}</span>
</span>
</div>
<div class="info-row">
<span class="info-label">Business Unit</span>
<span class="info-value">{{ computer.businessunitname || '-' }}</span>
</div>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="computer.assetid" />
<!-- All relationships (controls, defaultprinter, ...) -->
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
<!-- Notes -->
<div class="section-card" v-if="computer.notes">
<h3 class="section-title">Notes</h3>
<p class="notes-text">{{ computer.notes }}</p>
</div>
</div>
</div>
<!-- Software and Activity run FULL WIDTH, not in the two-column flow the
Details tab uses. Both are long lists rather than short fact cards:
an application inventory or an event feed split into balanced columns
reads as two unrelated lists, and the eye has to find where the first
one ended. -->
<div v-show="activegroup === 'software'" class="detail-full">
<PluginAssetPanels :assetid="computer.assetid" group="software" />
<div>
<div class="section-card" v-if="installedApps.length > 0">
<h3 class="section-title">Installed Applications</h3>
<div class="app-list">
<router-link
v-for="app in installedApps"
:key="app.id"
:to="`/applications/${app.appid}`"
class="app-item"
>
<div class="app-info">
<span class="app-name">{{ app.appname }}</span>
<span class="app-version" v-if="app.installedversion">v{{ app.installedversion }}</span>
</div>
<div class="app-desc" v-if="app.appdescription">
{{ app.appdescription }}
</div>
</router-link>
</div>
</div>
</div>
</div>
<!-- Monitors and Activity are entirely plugin-contributed: what is being
watched on the box, the service state and its controls, and the event
feed all belong to the endpoints plugin, and core knows nothing about
them.
Monitors keeps the two-column flow because it is short cards, and
Service/Process Monitors does the same: both are short fact cards
rather than the long lists that make Software and Activity go full
width. -->
<div v-show="activegroup === 'monitors'" class="content-grid">
<PluginAssetPanels
:assetid="computer.assetid"
group="monitors"
:exclude="['endpoints-interfaces', 'endpoints-mappeddrives']"
/>
</div>
<div v-show="activegroup === 'serviceprocess'" class="content-grid">
<PluginAssetPanels :assetid="computer.assetid" group="serviceprocess" />
</div>
<!-- Full width, unlike the two above: the thumbnail grid wants the room,
and it is the only card in this tab so there is nothing to flow
beside it. -->
<div v-show="activegroup === 'reports'" class="detail-full">
<PluginAssetPanels :assetid="computer.assetid" group="reports" />
</div>
<div v-show="activegroup === 'activity'" class="detail-full">
<PluginAssetPanels :assetid="computer.assetid" group="activity" />
</div>
<!-- Audit Footer -->
<div class="audit-footer">
<span>Created {{ formatDate(computer.createddate) }}<template v-if="computer.createdby"> by {{ computer.createdby }}</template></span>
<span>Modified {{ formatDate(computer.modifieddate) }}<template v-if="computer.modifiedby"> by {{ computer.modifiedby }}</template></span>
</div>
</template>
<div v-else class="card">
<p style="text-align: center; color: var(--text-light);">Computer not found</p>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { useRoute, useRouter } from 'vue-router'
import { computersApi, applicationsApi, endpointsApi } from '@/api'
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 AssetDetailTabs from '@/components/AssetDetailTabs.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { Copy } from 'lucide-vue-next'
import { getEntraDeviceUrls } from '@/utils/siteSettings'
import { setRecordTitle } from '@/composables/pageTitle'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const authStore = useAuthStore()
const { isEnabled } = useIdentifierFlags()
// Return-navigation origin. Fleet opens this page with ?from=fleet so Back,
// Edit, and Retire all lead back to Fleet; every other entry path falls back
// to the PCs list.
const fromFleet = computed(() => route.query.from === 'fleet')
const fromQuery = computed(() => (fromFleet.value ? '?from=fleet' : ''))
const returnTo = computed(() => (fromFleet.value ? '/endpoints' : '/pcs'))
const returnLabel = computed(() => (fromFleet.value ? 'Back to Fleet' : 'Back to List'))
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)
const installedApps = ref([])
const assetPanels = ref(null)
// Which tab is showing. Always opens on Details: it holds what the asset IS,
// which is the answer somebody arriving from a search or a link came for.
const activegroup = ref('details')
// Azure portal deep links, built from templates a site configures. Empty
// templates mean no link at all, which is the default: the host differs per
// tenant (this site is on the US Government cloud) and Microsoft moves these
// blade paths, so guessing one would send people to a dead page.
const entraUrlTemplate = ref('')
const intuneUrlTemplate = ref('')
const lapsUrlTemplate = ref('')
// The templates arrive from an async settings fetch. Until that settles the
// block stays hidden: rendering the ids first and popping the Intune/Entra
// buttons in a moment later reads as the buttons being missing, which is
// exactly what it looked like before a manual refresh warmed the cache.
const portalTemplatesLoaded = ref(false)
// The settings util negative-caches a failed load for 30s, so an immediate
// retry would just re-read the same empty result. Wait past that window.
const PORTAL_RETRY_MS = 31000
let portalRetryTimer = null
async function loadPortalTemplates({ allowRetry = true } = {}) {
try {
const { entraUrl, intuneUrl, lapsUrl, loaded } = await getEntraDeviceUrls()
if (!loaded && allowRetry) {
// Settings did not load. Leave the block hidden rather than paint a
// wrong "not configured" state, and try again once.
portalRetryTimer = setTimeout(() => {
portalRetryTimer = null
loadPortalTemplates({ allowRetry: false })
}, PORTAL_RETRY_MS)
return
}
entraUrlTemplate.value = entraUrl
intuneUrlTemplate.value = intuneUrl
lapsUrlTemplate.value = lapsUrl
} catch (err) {
// Ignore: templates stay empty and the block falls back to whatever else
// it has rather than hiding forever.
} finally {
// Only settle once the answer is trustworthy, or after the retry is spent.
if (!portalRetryTimer) portalTemplatesLoaded.value = true
}
}
onUnmounted(() => {
if (portalRetryTimer) clearTimeout(portalRetryTimer)
})
function fillTemplate(template, token, value) {
if (!template || !value) return null
return template.split(token).join(encodeURIComponent(value))
}
const entraDeviceLink = computed(() => fillTemplate(
entraUrlTemplate.value, '{objectid}', computer.value?.computer?.azureobjectid))
const intuneDeviceLink = computed(() => fillTemplate(
intuneUrlTemplate.value, '{deviceid}', computer.value?.computer?.azuredeviceid))
// LAPS is a blade on the same device as Entra, so it keys off the OBJECT id.
const lapsDeviceLink = computed(() => fillTemplate(
lapsUrlTemplate.value, '{objectid}', computer.value?.computer?.azureobjectid))
// clipboard.writeText needs a secure context: it is undefined over plain HTTP
// and rejects when the tab is not focused. Say copy failed and leave the id
// selectable rather than reporting a success that did not happen.
async function copyId(value, label) {
if (!value) return
try {
await navigator.clipboard.writeText(value)
toast.success(`${label} copied.`)
} catch (err) {
toast.error(`Copy failed - select the ${label} and copy manually.`)
}
}
// Whether to show the header's cloud block at all.
//
// Gated on the site having CONFIGURED a portal template, not on this PC having
// an id. A site that does not use Intune/Entra has no templates, so the block
// is absent entirely rather than permanently asking for an id it will never
// get. Where templates exist, a PC missing an id shows which one is missing -
// that is the actionable case the empty state was requested for.
const hasCloudIdentity = computed(() => portalTemplatesLoaded.value && Boolean(
entraUrlTemplate.value || intuneUrlTemplate.value || lapsUrlTemplate.value
|| computer.value?.computer?.azuredevicecategory))
// Only offer a tab that has something in it. An empty tab is worse than an
// absent one - it reads as broken rather than as "this site does not collect
// that". Details always shows because core fills it regardless of plugins.
//
// populatedGroups comes from PluginAssetPanels, which knows which panels
// actually returned data; Software is additionally true when core has an
// installed-applications list of its own.
const detailtabs = computed(() => {
const filled = assetPanels.value?.populatedGroups ?? new Set()
const has = (key) => filled.has ? filled.has(key) : false
const tabs = [{ key: 'details', label: 'Details' }]
// Monitors sits second: what is being watched on this box is the question
// asked right after "what is it", and well before its software inventory.
// Renamed to System Monitors now that service/process watching is its own
// tab: this one is the box's own health, that one is what runs on it.
if (has('monitors')) tabs.push({ key: 'monitors', label: 'System Monitors' })
if (has('serviceprocess')) {
tabs.push({ key: 'serviceprocess', label: 'Service/Process Monitors' })
}
// Operator Reports earns a tab rather than a card in System Monitors: it is
// what a person reported, not what the agent measured, and the thumbnail
// grid is much bulkier than the fact cards around it.
if (has('reports')) tabs.push({ key: 'reports', label: 'Operator Reports' })
if (has('software') || installedApps.value.length > 0) {
tabs.push({ key: 'software', label: 'Software', count: installedApps.value.length || null })
}
if (has('activity')) tabs.push({ key: 'activity', label: 'Activity' })
return tabs
})
// A tab can disappear on refetch - a plugin gets disabled, telemetry stops
// answering. Fall back to Details rather than leaving the page showing nothing.
watch(detailtabs, (tabs) => {
if (!tabs.some((tab) => tab.key === activegroup.value)) activegroup.value = 'details'
})
const restarting = ref(false)
const refreshing = ref(false)
// Header endpoint controls only make sense to users who can act on the agent.
// Restart is gated separately because it is a destructive grant (endpoints.restart)
// enforced again server-side; hiding the button is convenience, not the guard.
const canCommand = computed(() => authStore.hasPermission('endpoints.command'))
const canRestart = computed(() => authStore.hasPermission('endpoints.restart'))
// Warranty fetch + hero badge (shared across asset detail pages).
const { heroWarranty, warrantyDate: formatWarrantyDate } = useWarrantyBadge(() => computer.value?.assetid)
// 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 () => {
// Non-critical: a site that has not configured these just gets no link, so a
// failure here must not stop the page loading.
//
// A failed settings fetch is negative-cached for 30s and resolves EMPTY, which
// is indistinguishable from "not configured" by value alone - the block would
// hide itself and only appear on a later visit that happened to land after the
// window. Retry once past that window instead of showing a wrong empty state.
loadPortalTemplates()
try {
const response = await computersApi.get(route.params.id)
computer.value = response.data.data
setRecordTitle(computer.value?.assetnumber || computer.value?.name)
// Load installed applications
try {
const appsResponse = await applicationsApi.getMachineApps(route.params.id)
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)
} finally {
loading.value = false
}
})
function getStatusClass(status) {
if (!status) return 'badge-info'
const s = status.toLowerCase()
if (s === 'in use' || s === 'active') return 'badge-success'
if (s === 'in repair') return 'badge-warning'
if (s === 'retired') return 'badge-danger'
return 'badge-info'
}
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
// Force a live telemetry cycle rather than re-reading the cached snapshot.
// Enqueues collect_now (the agent reports off-cadence), waits for the agent to
// finish it, then re-fetches the panels so the cards reflect the endpoint's
// real current state (e.g. a process the operator just closed).
async function refreshStatus() {
if (refreshing.value) return
refreshing.value = true
try {
// Baseline the current telemetry timestamp BEFORE forcing a collect. A
// command reaching 'succeeded' only means the agent ran collect_now, not
// that it has posted the fresh snapshot yet, so we additionally wait for
// lastreportedat to move past this baseline before re-rendering the panels.
const baseline = await currentTelemetryStamp()
const response = await endpointsApi.enqueueCommand(computer.value.assetid, 'collect_now')
const commandId = response?.data?.data?.endpointcommandid
const outcome = await waitForCommand(commandId)
const fresh = outcome === 'succeeded'
? await waitForFreshTelemetry(baseline)
: false
await assetPanels.value?.refresh()
// The liveness chip reads its own endpoint, so refreshing the panels alone
// leaves it showing the age it was rendered with at page load - the button
// looked like it had done nothing. Re-read it whatever the outcome: a
// timeout or failure still moves the chip toward quiet, which is the
// honest reading.
await loadLiveness(computer.value?.assetid)
if (outcome === 'timeout') {
toast.info('Refresh queued; the endpoint agent may be offline or busy')
} else if (outcome === 'failed') {
toast.error('The endpoint agent could not refresh telemetry')
} else if (!fresh) {
toast.info('Refresh sent; the endpoint has not reported new data yet')
} else {
toast.success('Status refreshed')
}
} catch (error) {
toast.error(apiError(error, 'Failed to refresh status'))
} finally {
refreshing.value = false
}
}
// Read the endpoint's current lastreportedat (ISO string or null). Used as the
// freshness baseline; a null baseline means the box has never reported, so any
// non-null stamp later counts as fresh.
async function currentTelemetryStamp() {
try {
const response = await endpointsApi.telemetryMeta(computer.value.assetid)
return response?.data?.data?.lastreportedat || null
} catch {
return null
}
}
// Poll lastreportedat until it advances past the baseline (proof a new snapshot
// landed) or the attempt budget runs out. Returns true only on a confirmed
// newer stamp; a bounded false lets the caller still refresh and message that
// the box has not reported yet, rather than hanging the button on an idle agent.
async function waitForFreshTelemetry(baseline, { attempts = 15, delayMs = 1000 } = {}) {
const baseMs = baseline ? Date.parse(baseline) : 0
for (let i = 0; i < attempts; i += 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
const stamp = await currentTelemetryStamp()
if (stamp && Date.parse(stamp) > baseMs) return true
}
return false
}
// Poll the endpoint's recent commands until the given command reaches a terminal
// state or we give up. Returns 'succeeded' | 'failed' | 'timeout'. The agent
// long-polls, so a live box turns this around in a couple of seconds; an offline
// box lets it time out cleanly rather than hanging the button forever.
async function waitForCommand(commandId, { attempts = 15, delayMs = 1000 } = {}) {
if (!commandId) return 'timeout'
for (let i = 0; i < attempts; i += 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
try {
const response = await endpointsApi.listCommands(computer.value.assetid)
const command = (response?.data?.data || []).find(
(row) => row.endpointcommandid === commandId)
if (command && command.status === 'succeeded') return 'succeeded'
if (command && command.status === 'failed') return 'failed'
} catch {
// Transient poll failure: keep trying until the attempt budget runs out.
}
}
return 'timeout'
}
// Destructive: queues a system_restart that reboots the PC immediately
// (shutdown /r /t 0 on the agent). The confirm gate is the last stop before an
// unrecoverable action; the server re-checks endpoints.restart regardless.
async function confirmRestart() {
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
if (!confirm(`Restart ${label} now?\n\n`
+ 'The computer reboots immediately with no grace period. '
+ 'Any unsaved work on it will be lost.')) return
restarting.value = true
try {
await endpointsApi.enqueueCommand(computer.value.assetid, 'system_restart')
toast.success(`Restart queued for ${label}`)
} catch (error) {
toast.error(apiError(error, 'Failed to queue restart'))
} finally {
restarting.value = false
}
}
// Retire, not delete: the server deactivates the asset, so the PC
// leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true.
const isRetired = computed(() => computer.value?.isactive === false)
async function restore() {
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
try {
await computersApi.update(route.params.id, { isactive: true })
toast.success(`Restored ${label}`)
const response = await computersApi.get(route.params.id)
computer.value = response.data.data
} catch (error) {
console.error('Error restoring:', error)
toast.error(apiError(error, 'Failed to restore'))
}
}
async function confirmRetire() {
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
+ `History and relationships are kept, and it can be restored.`)) return
try {
await computersApi.delete(route.params.id)
toast.success(`Retired ${label}`)
router.push(returnTo.value)
} catch (error) {
console.error('Error retiring PC:', error)
toast.error(apiError(error, 'Failed to retire PC'))
}
}
</script>
<style scoped>
.retired-banner {
border-left: 3px solid var(--warning);
margin-bottom: 1rem;
}
.portal-link {
font-size: 0.78rem;
margin-left: 0.5rem;
padding: 0.05rem 0.4rem;
border: 1px solid var(--border);
border-radius: 3px;
color: var(--link, var(--primary));
text-decoration: none;
white-space: nowrap;
}
.portal-link:hover { border-color: var(--primary); }
/* PC-only header additions. These class names exist nowhere else in the app -
Machines, Printers and the rest share only .hero-details/.hero-detail-*
from global style.css, which is deliberately left alone. */
.hero-primary {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Identity on the left, liveness and endpoint actions pinned to the right.
.hero-primary is a COLUMN (identity, then the portals row beneath), so the
corner block needs its own row here rather than margin-left:auto against
the column, which would just stack it under the identity. */
.hero-headline {
display: flex;
align-items: flex-start;
gap: 1rem;
}
.hero-headline > .hero-identity { flex: 1 1 auto; }
@media (max-width: 768px) {
.hero-headline { flex-wrap: wrap; }
}
.hero-identity {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0;
}
/* Horizontal, in the same visual language as .hero-details directly above it,
separated by a rule rather than sitting in a box. Wraps to a second line on
a narrow window instead of forcing a scrollbar. */
.hero-portals {
display: flex;
flex-wrap: wrap;
gap: 2rem;
padding-top: 0.85rem;
border-top: 1px solid var(--border);
}
.hero-portal {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
/* The portal button lives on the label line, so it reads as an action on the
id below it and adds no height of its own. */
.hero-portal-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-light);
}
.hero-portal-value {
font-size: 15px;
font-weight: 500;
color: var(--text);
}
/* Full id, never truncated: a half-copied GUID looks complete and is worse
than none. user-select:all keeps manual selection working as a fallback for
when the clipboard API is unavailable over plain HTTP. */
.hero-portal-idrow {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.hero-portal-id {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 13px;
color: var(--text);
user-select: all;
white-space: nowrap;
}
.copy-btn {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.2rem;
line-height: 0;
color: var(--text-light);
background: none;
border: 1px solid var(--border);
border-radius: 0.25rem;
cursor: pointer;
}
/* Liveness + endpoint actions in the header corner. Pinned to the top right of
.hero-primary, which is the flex row holding the identity block. */
.hero-endpoint {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.75rem;
margin-left: auto;
flex: 0 0 auto;
padding-left: 1.5rem;
}
.hero-status {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.45rem;
}
/* A touch larger than the shared hero label (10px in style.css). Scoped to
this one rather than bumping .hero-detail-label, which PC Type / OS /
Location also use and which is sized to sit quietly under the identity. */
.hero-status-label {
font-size: 12px;
}
.hero-endpoint-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
/* The chip styles live in AssetTriageBand's scoped block, so they do not reach
this copy of the markup. Kept in step with that component deliberately. */
.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);
white-space: nowrap;
}
.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); }
[data-theme="dark"] .triage-chip.ok { color: var(--success); }
[data-theme="dark"] .triage-chip.warning { color: var(--warning); }
/* The Details tab flows its cards instead of sitting on grid rows.
Grid pairs items into implicit rows, so a row is as tall as its tallest
card and align-items:start leaves the shorter one with dead space beneath
it - Warranty is short, Endpoint Status is tall, and Policy has to wait for
the taller of the two instead of sliding up. Multicol has no rows, so each
card starts where the previous one ended and the holes close.
Multicol was rejected for .content-grid globally because a column-span:all
child (.pap-span, used by table panels) overlaps the shorter column - see
the note by .content-grid in style.css. That cannot happen here: this tab's
panels are all keyvalue/list renders and spansFullWidth() only returns true
for table (or a tabs panel holding one), so nothing in this subtree spans.
Monitors and Service/Process DO carry table panels and stay on grid.
Pure CSS, so there is no measuring, no observer and no reflow cost - the
JS-masonry option was declined for exactly that reason.
The tradeoff multicol brings is reading order: cards run down the left
column then down the right, so DOM order still matches visual order here
(it is the same top-to-bottom-per-column flow), and column-count keeps
focus order intact because no item is visually reordered across columns. */
.content-grid.details-flow {
display: block;
column-count: 2;
column-gap: 25px;
}
/* display:contents on .content-column would leave the wrappers invisible to
multicol as well, but the cards inside them need the break rule, so target
the cards themselves wherever they sit. :deep() because the plugin panels
are rendered by PluginAssetPanels and carry that component's scope id. */
.content-grid.details-flow :deep(.section-card) {
break-inside: avoid;
/* A card split across the column break would show its header in one column
and its rows in the other. */
-webkit-column-break-inside: avoid;
page-break-inside: avoid;
/* Multicol has no gap between stacked items the way grid does, so the
spacing that grid's gap supplied has to come back as a margin. */
margin-bottom: 25px;
}
/* One column below the hero breakpoint, matching .content-grid's own
single-column collapse so the two tabs behave the same on a phone. */
@media (max-width: 768px) {
.content-grid.details-flow {
column-count: 1;
}
}
/* Restart is the heaviest action on the page and sat visually equal to the
reference chips beside it. ~25% up from .btn-sm (10px / 6px 15px) so the
weight matches the consequence. Scoped with :deep() because the button is
passed into AssetTriageBand's slot, so the band's scope id lands on it. */
:deep(.triage-actions .btn-warning) {
font-size: 12.5px;
padding: 7.5px 19px;
}
/* Below the hero's two-column breakpoint the corner block would strand the
buttons against the right edge of a narrow screen. */
@media (max-width: 768px) {
.hero-endpoint {
margin-left: 0;
align-items: flex-start;
width: 100%;
}
.hero-status { align-items: flex-start; }
.hero-endpoint-actions { justify-content: flex-start; }
}
.copy-btn:hover {
color: var(--primary);
border-color: var(--primary);
}
.hero-portal-missing {
font-size: 13px;
color: var(--text-light);
font-style: italic;
}
@media (max-width: 900px) {
.hero-portals { gap: 1rem 1.5rem; }
/* Only here does the id need to break: at this width it cannot fit a line. */
.hero-portal-id { white-space: normal; overflow-wrap: anywhere; }
}
/* PC-specific styles - shared styles are in global style.css */
/* Remote-access protocol links */
.access-methods {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.access-methods .muted {
color: var(--text-light);
}
.access-link {
display: inline-block;
padding: 3px 12px;
margin: 0 6px 4px 0;
border-radius: 14px;
background: var(--primary);
color: #fff;
font-size: 0.82rem;
font-weight: 600;
text-decoration: none;
}
.access-link:hover {
background: var(--primary-dark);
text-decoration: none;
}
.access-link.disabled {
background: var(--secondary);
opacity: 0.5;
cursor: not-allowed;
}
/* Installed Applications */
.app-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.app-item {
display: block;
padding: 1rem;
background: var(--bg);
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: background 0.15s;
}
.app-item:hover {
background: var(--border);
text-decoration: none;
}
.app-info {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.app-name {
font-weight: 500;
font-size: 1.125rem;
color: var(--text);
}
.app-version {
font-size: 1rem;
color: var(--text-light);
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.app-desc {
margin-top: 0.375rem;
font-size: 1rem;
color: var(--text-light);
}
/* Network details */
.network-details {
margin-top: 0.5rem;
display: flex;
gap: 1.25rem;
font-size: 1rem;
color: var(--text-light);
}
/* Network card styles removed with the card itself - IP History and IP
Configuration are plugin panels and carry their own table styling. */
.muted {
color: var(--text-light);
margin: 0;
}
</style>