Files
shopdb-flask/plugins/computers/frontend/views/PCDetail.vue
benjamin.baker 86985efcf2 Fix user-context collection, split agent log destinations, widen upload cap
Mapped drives stopped reporting because the user-context path failed silently
in three separate places, and none of them left a trace to read back.

- Probe: launch via -EncodedCommand instead of staging a temp .ps1 and running
  powershell.exe -File. A missing or quarantined script file fails BEFORE any
  statement runs, so the script's own try/catch never sees it and the only
  symptom is exit -196608.
- Spool: pin to the ShopFloor profile. Resolving it through WTSQueryUserToken
  is privileged, endpoint protection blocks it, and on failure the tray
  (writer) and service (reader) silently disagree about the path.
- Spool: consume after a successful parse, so a stopped tray cannot keep
  re-reporting stale mapped drives as current. Unparseable files are left in
  place for the next cycle.
- Logging: user-session components cannot write to Program Files or to the
  SYSTEM-owned ProgramData log directory, and the file sink degrades to a
  silent no-op when it cannot create its directory. Tray and probe now log to
  C:\Users\ShopFloor\ShopDB\Logs, installs transcript to ProgramData, and the
  sink records a SinkProblem instead of swallowing UnauthorizedAccessException.
- Installer: provision the operator spool/log directories with ShopFloor:Modify
  before the tray first runs, and stop the transcript via trap so a throw
  cannot orphan exactly the failed install worth reading.
- Upload cap 64 -> 128 MB: the self-contained tray publishes at ~69 MB and was
  rejected on SIZE, but the size check returns before the PE is parsed, so the
  operator only ever saw a version-detection failure.

AGENT-CONTRACT.md gains a normative section defining the three security
contexts, their log destinations, the fixed spool path and consume-on-read.

Agent 1.8.0 -> 1.8.1, tray 1.1.0 -> 1.1.1 so the deployed build is
identifiable from a log line.
2026-09-05 14:20:07 -04:00

995 lines
41 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-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>
<!-- 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"
@click="copyId(computer.computer.azuredeviceid, 'Device ID')"
>Copy</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>
</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"
@click="copyId(computer.computer.azureobjectid, 'Object ID')"
>Copy</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. -->
<AssetTriageBand
:assetid="computer.assetid"
:liveness="liveness"
:accessmethods="computer.accessmethods || []"
>
<template #actions>
<button v-if="canCommand" @click="refreshStatus" class="btn btn-secondary btn-sm"
:disabled="restarting || refreshing">
{{ refreshing ? 'Refreshing...' : 'Refresh Status' }}
</button>
<button v-if="canRestart" @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">
<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. -->
<!-- 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">
<span class="info-label">Logged In User</span>
<span class="info-value">{{ computer.computer?.loggedinuser || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Last Reported</span>
<span class="info-value">{{ computer.computer?.lastreporteddate ? formatDate(computer.computer.lastreporteddate) : 'Never' }}</span>
</div>
<div class="info-row">
<span class="info-label">Last Boot</span>
<span class="info-value">{{ computer.computer?.lastboottime ? formatDate(computer.computer.lastboottime) : '-' }}</span>
</div>
</div>
</div>
<!-- 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 } 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 { 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)
// 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)
// 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('')
// 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)
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))
// 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
|| 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.
getEntraDeviceUrls().then(({ entraUrl, intuneUrl }) => {
entraUrlTemplate.value = entraUrl
intuneUrlTemplate.value = intuneUrl
}).catch(() => {}).finally(() => {
// Settled either way. On failure the templates stay empty and the block
// falls back to whatever else it has, rather than hiding forever.
portalTemplatesLoaded.value = true
})
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()
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;
}
.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;
padding: 0.1rem 0.4rem;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-light);
background: none;
border: 1px solid var(--border);
border-radius: 0.25rem;
cursor: pointer;
}
.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>