Adds a shared triage band to the PC detail hero: is it alive, what stops
without it, how do I reach it.
'Serves machine' is the reason it exists. On a shop floor a PC matters because
a machine stops without it, and that fact was buried in the generic
relationships card at the bottom of the page. Both edge directions are read,
deduplicated, and filtered to assets typed 'machine' - production holds
reciprocal rows, so reading one direction shows the link on half the pages and
not the other half.
The band is a CORE component, not a PCDetail block. Five asset detail pages
already rebuild the same hero and grid independently, which is how they drifted
apart; machines, printers and network devices can drop this in rather than
growing a sixth copy. Liveness is passed in rather than fetched, because only a
plugin that collects telemetry can answer it and a site may not have one.
The status chip's verdict comes from the SERVER: /api/endpoints/<id> now
returns the same reporting/quiet/never it computes for the fleet list. The
quiet window is a site setting, so a threshold guessed in the browser would
disagree with the Fleet list about the same endpoint the moment a site changed
it.
Two cards removed from the body:
- Remote Access duplicated the band's chips. It is what a responder clicks
first, so it belongs above the fold, once.
- Status was worse than duplication. It reads the COMPUTER RECORD, while the
endpoint panel beside it reads live agent telemetry, and on an
agent-managed PC the record fields sit empty - so the page rendered 'Last
Reported: Never' a few inches from live telemetry saying otherwise. It now
renders only when that record actually holds something, which is the case
on sites running the asset-report script instead of the agent.
Verified against dev telemetry: band shows 'Reporting, 1 h ago', the machine it
serves, and its access chips; both duplicate cards are gone.
vitest 260 passed, naming/contract/parity gates green.
663 lines
26 KiB
Vue
663 lines
26 KiB
Vue
<template>
|
|
<div class="detail-page">
|
|
<div class="page-header">
|
|
<h2>Computer Details</h2>
|
|
<div class="header-actions">
|
|
<button v-if="canCommand" @click="refreshStatus" class="btn btn-secondary" :disabled="restarting || refreshing">{{ refreshing ? 'Refreshing...' : 'Refresh Status' }}</button>
|
|
<button v-if="canRestart" @click="confirmRestart" class="btn btn-warning" :disabled="restarting">Restart Computer</button>
|
|
<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`" class="btn btn-primary">Edit</router-link>
|
|
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
|
|
<router-link to="/pcs" class="btn btn-secondary">Back to List</router-link>
|
|
</div>
|
|
</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 || []"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
|
<!-- Main Content Grid -->
|
|
<div class="content-grid">
|
|
<!-- 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">
|
|
<div v-if="computer.computer?.azuredeviceid" class="info-row">
|
|
<span class="info-label">Device ID</span>
|
|
<span class="info-value">{{ computer.computer.azuredeviceid }}</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 }}</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>
|
|
|
|
<!-- Installed Applications -->
|
|
<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>
|
|
|
|
<!-- Custom Fields -->
|
|
<CustomFieldsSection :assetid="computer.assetid" />
|
|
|
|
<!-- Warranty -->
|
|
<PluginAssetPanels ref="assetPanels" :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>
|
|
|
|
<!-- 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, 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 AssetRelationships from '@/components/AssetRelationships.vue'
|
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
|
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()
|
|
|
|
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)
|
|
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 () => {
|
|
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.
|
|
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('/pcs')
|
|
} catch (error) {
|
|
console.error('Error retiring PC:', error)
|
|
toast.error(apiError(error, 'Failed to retire PC'))
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* 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>
|