Tracked Services and Tracked Processes were sharing a tab with system health cards, so what is running on the box and how the box itself is doing read as one list. They now sit in a Service/Process Monitors tab and the old tab is System Monitors. The group vocabulary is duplicated across the renderer, the tab map and the panel declarations with nothing checking they agree, and a group named in one but not the others is dropped in silence - that has eaten cards twice. Tests now pin the vocabulary on both sides. Boot type and NIC link state were collected in Phase 2 but shown nowhere. Boot type only renders when fast startup applies, because uptime measured from a resume understates how long the kernel has been up and that is the case worth naming. Link state leaves a disconnected NIC unknown rather than half duplex: live hardware returns empty speed and duplex when unplugged, and half duplex is the cable fault signature this column exists to catch.
845 lines
35 KiB
Vue
845 lines
35 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">
|
|
<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>
|
|
|
|
<!-- 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" />
|
|
|
|
<div v-show="activegroup === 'details'" class="content-grid">
|
|
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" group="details" />
|
|
|
|
<!-- 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>
|
|
|
|
<!-- Entra / Azure device info -->
|
|
<div
|
|
v-if="computer.computer?.azuredeviceid || computer.computer?.azureobjectid || computer.computer?.azuredevicecategory"
|
|
class="section-card"
|
|
>
|
|
<h3 class="section-title">Entra Device Info</h3>
|
|
<div class="info-list">
|
|
<!-- The link sits beside the id it is built from, not in a row
|
|
of its own at the top: 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. -->
|
|
<div v-if="computer.computer?.azuredeviceid" class="info-row">
|
|
<span class="info-label">Device ID</span>
|
|
<span class="info-value">
|
|
{{ computer.computer.azuredeviceid }}
|
|
<a
|
|
v-if="intuneDeviceLink"
|
|
:href="intuneDeviceLink"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="portal-link"
|
|
>Intune</a>
|
|
</span>
|
|
</div>
|
|
<div v-if="computer.computer?.azureobjectid" class="info-row">
|
|
<span class="info-label">Object ID</span>
|
|
<span class="info-value">
|
|
{{ computer.computer.azureobjectid }}
|
|
<a
|
|
v-if="entraDeviceLink"
|
|
:href="entraDeviceLink"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="portal-link"
|
|
>Entra</a>
|
|
</span>
|
|
</div>
|
|
<div v-if="computer.computer?.azuredevicecategory" class="info-row">
|
|
<span class="info-label">Device Category</span>
|
|
<span class="info-value">{{ computer.computer.azuredevicecategory }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Column -->
|
|
<div class="content-column">
|
|
<!-- Network -->
|
|
<div class="section-card">
|
|
<h3 class="section-title">Network</h3>
|
|
<div v-if="computer.communications?.length" class="network-list">
|
|
<div v-for="comm in computer.communications" :key="comm.communicationid" class="network-item">
|
|
<div class="network-primary">
|
|
<span class="ip-address mono">{{ comm.ipaddress || comm.address || '-' }}</span>
|
|
<span v-if="comm.communicationtypename" class="comm-type">{{ comm.communicationtypename }}</span>
|
|
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
|
|
</div>
|
|
<div class="network-secondary" v-if="comm.macaddress">
|
|
<span class="mac-address mono">{{ comm.macaddress }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p v-else class="muted">No network addresses on record</p>
|
|
</div>
|
|
|
|
<!-- 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" />
|
|
</div>
|
|
<div v-show="activegroup === 'serviceprocess'" class="content-grid">
|
|
<PluginAssetPanels :assetid="computer.assetid" group="serviceprocess" />
|
|
</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('')
|
|
|
|
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))
|
|
|
|
// 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' })
|
|
}
|
|
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(() => {})
|
|
|
|
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-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 (IP / MAC list) */
|
|
.network-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.6rem;
|
|
}
|
|
.network-item {
|
|
padding: 0.5rem 0.7rem;
|
|
background: var(--bg);
|
|
border-radius: 6px;
|
|
}
|
|
.network-primary {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.ip-address {
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
}
|
|
.comm-type {
|
|
font-size: 0.8rem;
|
|
color: var(--text-light);
|
|
}
|
|
.primary-badge {
|
|
padding: 0.1rem 0.5rem;
|
|
font-size: 0.72rem;
|
|
font-weight: 600;
|
|
background: var(--primary);
|
|
color: #fff;
|
|
border-radius: 10px;
|
|
}
|
|
.mac-address {
|
|
font-size: 0.82rem;
|
|
color: var(--text-light);
|
|
}
|
|
.muted {
|
|
color: var(--text-light);
|
|
margin: 0;
|
|
}
|
|
</style>
|