Files
shopdb-flask/plugins/computers/frontend/views/PCDetail.vue
benjamin.baker f73df42110 Show the PC alias as "Alias" in Fleet and PC Details
The asset name field presented as "Name" in PC Details but as "Alias" in Edit
PC, and was absent from Fleet entirely. Three surfaces, two words, one field.

Adds an Alias column to the Fleet table (positioned after Asset Tag to mirror
PC Details' ordering) and renames the PC Details label to "Alias". Edit PC
already used "Alias", so it is unchanged.

Label and presentation only: the field stays `name` on the asset and the API
payload is untouched, so no backend contract moves.
2026-09-07 13:03:31 -04:00

2043 lines
82 KiB
Vue

<template>
<div class="detail-page">
<div class="page-header">
<h2>Computer Details</h2>
<!-- Record actions only. The two that act on the MACHINE - refresh and
restart - live in the triage band beside the state they change and
the machine they would take down with it. Restart sitting between
Print Label and Edit made the most consequential button on the page
look like a peer of printing a sticker. -->
<div class="header-actions">
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
<router-link :to="`/pcs/${$route.params.id}/edit${fromQuery}`" class="btn btn-primary">Edit</router-link>
<button v-if="!isRetired" @click="confirmRetire" class="btn btn-danger">Retire</button>
<!-- A retired record offered Edit and Retire exactly like a live
one, and nothing said it was retired. Restoring it was possible
through the API and nowhere in the product. -->
<button v-if="isRetired" @click="restore" class="btn btn-primary">Restore</button>
<router-link :to="returnTo" class="btn btn-secondary">{{ returnLabel }}</router-link>
</div>
</div>
<div v-if="computer && computer.isactive === false" class="retired-banner card">
<strong>This PC is retired.</strong>
It stays out of every list and report, and its history and relationships
are intact. Restore puts it back.
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="computer">
<!-- Hero Section -->
<div class="hero-card">
<div class="hero-image" v-if="computer.computer?.imageurl">
<img :src="withBase(computer.computer.imageurl)" :alt="computer.computer.modelname || 'Model photo'" />
</div>
<div class="hero-content">
<!-- Identity, then the cloud row beneath it. An earlier cut put the
portal block in a fixed-width rail on the right; GUIDs are too
long for a narrow column and it wrapped into a tall ragged
stack. Full width in the normal flow costs one short row. -->
<div class="hero-primary">
<div class="hero-headline">
<div class="hero-identity">
<div class="hero-title">
<h1>{{ computer.assetnumber }}</h1>
<span v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
</div>
<div class="hero-meta">
<span class="badge badge-lg badge-info">Computer</span>
<span class="badge badge-lg" :style="colorStyle(computer.statuscolor)">
{{ computer.statusname || 'Unknown' }}
</span>
<span v-if="heroWarranty" class="badge badge-lg" :style="colorStyle(heroWarranty.statuscolor)"
:title="heroWarranty.enddate ? `Warranty ends ${formatWarrantyDate(heroWarranty.enddate)}` : 'Warranty'">
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ formatWarrantyDate(heroWarranty.enddate) }}</template>
</span>
</div>
<div class="hero-details">
<div class="hero-detail" v-if="computer.computer?.computertypename">
<span class="hero-detail-label">PC Type</span>
<span class="hero-detail-value">{{ computer.computer.computertypename }}</span>
</div>
<div class="hero-detail" v-if="computer.computer?.osname">
<span class="hero-detail-label">OS</span>
<span class="hero-detail-value">{{ computer.computer.osname }}</span>
</div>
<div class="hero-detail" v-if="computer.locationname">
<span class="hero-detail-label">Location</span>
<span class="hero-detail-value">{{ computer.locationname }}</span>
</div>
</div>
</div>
<!-- Liveness and the endpoint actions, pulled out of the triage
band into the header corner. They are the two things looked at
first on a PC that is misbehaving, and at the bottom of the
hero they sat below the fold on a laptop. The band keeps the
reference facts (serves machine, remote access, location). -->
<div class="hero-endpoint">
<div v-if="liveness" class="hero-status">
<span class="hero-detail-label hero-status-label">Status</span>
<span class="triage-chip" :class="liveness.level">
<span class="triage-dot"></span>{{ liveness.label }}
</span>
</div>
<div v-if="canCommand" class="hero-endpoint-actions">
<button @click="refreshStatus" class="btn btn-secondary btn-sm"
:disabled="restarting || refreshing">
{{ refreshing ? 'Refreshing...' : 'Refresh Status' }}
</button>
</div>
</div>
<!-- Endpoint lock. Rendered ABOVE the holds and in a heavier
treatment because it outranks them: a hold pauses one automatic
action, a lock stops every user but an admin from doing
anything here at all. Same-looking banners would train people
to read the two as synonyms. -->
<div v-if="endpointlock" class="endpoint-lock">
<span class="endpoint-lock-tag">LOCKED</span>
<span class="endpoint-lock-text">
by {{ endpointlock.lockedbyusername || 'a deleted user' }}
- {{ endpointlock.reason }}
</span>
<button v-if="canHoldPolicy" class="endpoint-lock-release"
:disabled="lockbusy"
@click="confirmReleaseLock">
{{ lockbusy ? 'Working...' : 'Unlock' }}
</button>
</div>
<!-- Active break-glass holds. Shown to every viewer, not just
admins: while a hold stands this PC is deliberately NOT being
reconciled to policy, and an operator seeing drift needs to
know it was authorized rather than chase a phantom fault. -->
<div v-if="policyholds.length" class="policy-holds">
<div v-for="hold in policyholds" :key="hold.action"
class="policy-hold">
<span class="policy-hold-action">{{ hold.action }}</span>
<span class="policy-hold-text">
policy enforcement paused by
{{ hold.createdbyusername || 'a deleted user' }}
until {{ holdExpiryLabel(hold) }} - {{ hold.reason }}
</span>
<!-- Release sits on the row, not in the header: holds are
per-action and several can stand at once, so a single
header button could only guess which one to end. -->
<button v-if="canHoldPolicy" class="policy-hold-release"
:disabled="holdbusy"
@click="confirmReleaseHold(hold)">Release</button>
</div>
</div>
</div>
<!-- Cloud identity. Laid out as a horizontal row in the same
label-over-value language as hero-details, not as a narrow
right rail: a 36-character GUID cannot wrap sanely into a
300px column, and the ragged stack that produced grew taller
than the identity block beside it.
Held back until the portal templates resolve so the row does
not paint once without its buttons and then again with them. -->
<div v-if="hasCloudIdentity" class="hero-portals">
<!-- The link sits beside the id it is built from, not in a row
of its own: the pairing is counterintuitive - the DEVICE id
opens Intune and the OBJECT id opens Entra - and a separate
action row would have to re-label which is which. The full
id stays selectable because it gets copied into tickets. -->
<div class="hero-portal">
<span class="hero-portal-label">
Device ID
<a
v-if="intuneDeviceLink"
:href="intuneDeviceLink"
target="_blank"
rel="noopener noreferrer"
class="portal-link"
>Intune</a>
</span>
<span v-if="computer.computer?.azuredeviceid" class="hero-portal-idrow">
<span class="hero-portal-id mono">{{ computer.computer.azuredeviceid }}</span>
<button
type="button"
class="copy-btn"
title="Copy Device ID"
aria-label="Copy Device ID"
@click="copyId(computer.computer.azuredeviceid, 'Device ID')"
><Copy :size="14" aria-hidden="true" /></button>
</span>
<span v-else class="hero-portal-missing">Please provide DeviceID</span>
</div>
<div class="hero-portal">
<span class="hero-portal-label">
Object ID
<a
v-if="entraDeviceLink"
:href="entraDeviceLink"
target="_blank"
rel="noopener noreferrer"
class="portal-link"
>Entra</a>
<a
v-if="lapsDeviceLink"
:href="lapsDeviceLink"
target="_blank"
rel="noopener noreferrer"
class="portal-link"
title="Local administrator password (LAPS)"
>Azure LAPS</a>
</span>
<span v-if="computer.computer?.azureobjectid" class="hero-portal-idrow">
<span class="hero-portal-id mono">{{ computer.computer.azureobjectid }}</span>
<button
type="button"
class="copy-btn"
title="Copy Object ID"
aria-label="Copy Object ID"
@click="copyId(computer.computer.azureobjectid, 'Object ID')"
><Copy :size="14" aria-hidden="true" /></button>
</span>
<span v-else class="hero-portal-missing">Please provide ObjectID</span>
</div>
<div class="hero-portal" v-if="computer.computer?.azuredevicecategory">
<span class="hero-portal-label">Category</span>
<span class="hero-portal-value">{{ computer.computer.azuredevicecategory }}</span>
</div>
</div>
</div>
<!-- The 6am band: is it alive, what stops without it, how do I reach
it. "Serves machine" used to sit in the relationships card near
the bottom of the page, which is the wrong place for the single
most useful fact about a shop-floor PC. -->
<!-- Liveness and Refresh Status moved to .hero-endpoint in the header
corner. Restart stays here: it is destructive and takes the
machine down with it, so it belongs beside the "serves machine"
fact that says what stops, not next to a read-only refresh. -->
<AssetTriageBand
:assetid="computer.assetid"
:accessmethods="computer.accessmethods || []"
>
<template #actions v-if="canRestart || canHoldPolicy">
<!-- Admin only, and FIRST in the band. Placing a hold switches OFF
automatic policy enforcement, so it sits with the destructive
controls rather than with the read-only refresh. Red rather
than grey: grey undersold that this disables enforcement.
Leading the band because it is the control an admin reaches
for BEFORE restarting a machine they are about to work on. -->
<button v-if="canHoldPolicy" @click="openHoldDialog"
class="btn btn-danger btn-sm"
:disabled="holdbusy">
{{ holdbusy ? 'Working...' : 'Admin Hold' }}
</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 details-flow">
<PluginAssetPanels
ref="assetPanels"
:assetid="computer.assetid"
group="details"
/>
<!-- Mapped drives is lifted the same way as network interfaces: a
missing share is a "why can't this operator open their program"
question, which gets asked on Details, not under monitors. It
stays declared in monitors and is excluded from that tab. -->
<PluginAssetPanels
:assetid="computer.assetid"
:only="['endpoints-mappeddrives']"
/>
<!-- Left Column -->
<div class="content-column">
<!-- Identity Section -->
<div class="section-card">
<h3 class="section-title">Identity</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Asset Number</span>
<span class="info-value">{{ computer.assetnumber }}</span>
</div>
<div class="info-row" v-if="computer.name">
<span class="info-label">Alias</span>
<span class="info-value">{{ computer.name }}</span>
</div>
<div class="info-row" v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname">
<span class="info-label">Hostname</span>
<span class="info-value mono">{{ computer.computer.hostname }}</span>
</div>
<div class="info-row" v-if="computer.serialnumber">
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ computer.serialnumber }}</span>
</div>
<div class="info-row" v-if="isEnabled('gaugelabreference', 'computer') && computer.gaugelabreference">
<span class="info-label">Gauge Lab Reference</span>
<span class="info-value mono">{{ computer.gaugelabreference }}</span>
</div>
<div class="info-row" v-if="isEnabled('maintenancereference', 'computer') && computer.maintenancereference">
<span class="info-label">Maintenance Reference</span>
<span class="info-value mono">{{ computer.maintenancereference }}</span>
</div>
</div>
</div>
<!-- Hardware Section -->
<div class="section-card">
<h3 class="section-title">Hardware</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Computer Type</span>
<span class="info-value">{{ computer.computer?.computertypename || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">
{{ computer.computer?.vendorname || '-' }}
<!-- Inherited from the catalog model, not stored on this record. -->
<small v-if="computer.computer?.vendorfrommodel" class="text-muted">(from model)</small>
</span>
</div>
<div class="info-row">
<span class="info-label">Model</span>
<span class="info-value">{{ computer.computer?.modelname || '-' }}</span>
</div>
<!-- Only when it DIFFERS from this asset's own type. The two are
separate tables - modeltypes spans every kind of asset - but
they carry the same names in practice, so showing both
always put two identical Type rows in one section. Worth
seeing only when they disagree. -->
<div class="info-row"
v-if="computer.computer?.modeltypename && computer.computer.modeltypename !== computer.computer?.computertypename">
<span class="info-label">Model type</span>
<span class="info-value">{{ computer.computer.modeltypename }}</span>
</div>
<div class="info-row">
<span class="info-label">Operating System</span>
<span class="info-value">{{ computer.computer?.osname || '-' }}</span>
</div>
</div>
</div>
<!-- Remote Access lives in the triage band at the top of the page
now: it is what a responder clicks first, and rendering it twice
made the page longer without telling anyone anything. -->
<!-- The record-checkin Status card lived here (Logged In User, Last
Reported, Last Boot), sourced from the asset-report collector
rather than the agent. It restated what the Endpoint status panel
already shows from live telemetry, and the two sources phrase the
same facts differently, so the page appeared to disagree with
itself. The panel is the better source: it is populated by the
agent and carries uptime, versions and drift alongside. -->
<!-- The Entra Device Info card lived here. Its links moved to the
header's right-hand block, which was dead space at every width,
so they now cost no vertical room and sit beside the identity
they belong to rather than partway down the page. -->
</div>
<!-- Right Column -->
<div class="content-column">
<!-- The hand-written Network card lived here. It listed current
addresses with a Primary badge and nothing else, which undersold
rows that also carry subnet, gateway, DNS, and their own
first-seen/last-changed stamps. Split into the IP History and IP
Configuration panels under System Monitors, which read the same
Communication rows. -->
<!-- Location & Organization -->
<div class="section-card">
<h3 class="section-title">Location & Organization</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Location</span>
<span class="info-value">
<LocationMapTooltip
v-if="computer.mapx != null && computer.mapy != null"
:left="computer.mapx"
:top="computer.mapy"
:levelid="computer.levelid"
:machineName="computer.assetnumber"
>
<span class="location-link">{{ computer.locationname || 'On Map' }}</span>
</LocationMapTooltip>
<span v-else>{{ computer.locationname || '-' }}</span>
</span>
</div>
<div class="info-row">
<span class="info-label">Business Unit</span>
<span class="info-value">{{ computer.businessunitname || '-' }}</span>
</div>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="computer.assetid" />
<!-- All relationships (controls, defaultprinter, ...) -->
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
<!-- Notes -->
<div class="section-card" v-if="computer.notes">
<h3 class="section-title">Notes</h3>
<p class="notes-text">{{ computer.notes }}</p>
</div>
</div>
</div>
<!-- Software and Activity run FULL WIDTH, not in the two-column flow the
Details tab uses. Both are long lists rather than short fact cards:
an application inventory or an event feed split into balanced columns
reads as two unrelated lists, and the eye has to find where the first
one ended. -->
<div v-show="activegroup === 'software'" class="detail-full">
<PluginAssetPanels :assetid="computer.assetid" group="software" />
<div>
<div class="section-card" v-if="installedApps.length > 0">
<h3 class="section-title">Installed Applications</h3>
<div class="app-list">
<router-link
v-for="app in installedApps"
:key="app.id"
:to="`/applications/${app.appid}`"
class="app-item"
>
<div class="app-info">
<span class="app-name">{{ app.appname }}</span>
<span class="app-version" v-if="app.installedversion">v{{ app.installedversion }}</span>
</div>
<div class="app-desc" v-if="app.appdescription">
{{ app.appdescription }}
</div>
</router-link>
</div>
</div>
</div>
</div>
<!-- Monitors and Activity are entirely plugin-contributed: what is being
watched on the box, the service state and its controls, and the event
feed all belong to the endpoints plugin, and core knows nothing about
them.
Monitors keeps the two-column flow because it is short cards, and
Service/Process Monitors does the same: both are short fact cards
rather than the long lists that make Software and Activity go full
width. -->
<div v-show="activegroup === 'monitors'" class="content-grid">
<PluginAssetPanels
:assetid="computer.assetid"
group="monitors"
:exclude="['endpoints-interfaces', 'endpoints-mappeddrives']"
/>
</div>
<div v-show="activegroup === 'serviceprocess'" class="content-grid">
<PluginAssetPanels :assetid="computer.assetid" group="serviceprocess" />
</div>
<!-- Full width, unlike the two above: the thumbnail grid wants the room,
and it is the only card in this tab so there is nothing to flow
beside it. -->
<div v-show="activegroup === 'reports'" class="detail-full">
<PluginAssetPanels :assetid="computer.assetid" group="reports" />
</div>
<div v-show="activegroup === 'activity'" class="detail-full">
<PluginAssetPanels :assetid="computer.assetid" group="activity" />
</div>
<!-- Corporate Tools: fixed triggers for scheduled tasks already
registered on the endpoint. The agent only STARTS a task it already
holds and that clears its approved-task allowlist - it never defines
one - so the endpoint stays the authority on what actually runs. -->
<div v-show="activegroup === 'corptools'" class="detail-full">
<div class="card">
<h3>Corporate Tools</h3>
<p class="corptools-intro">
Starts a scheduled task that is already registered on this computer.
Each task runs in the security context its own definition specifies.
</p>
<!-- Persistent by design: not a dismissible toast and not shown only
on click. These tasks run real corporate tooling as SYSTEM on a
machine someone may be sitting at, and the warning has to still
be on screen at the moment an operator decides to press Run. -->
<p class="corptools-warning">
These actions take effect immediately on the live computer and
cannot be undone from here. A Dell update run can reboot the machine
and may interrupt the person using it.
</p>
<div class="corptools-list">
<div v-for="tool in corporateTools" :key="tool.task" class="corptools-row">
<div class="corptools-text">
<div class="corptools-name">{{ tool.label }}</div>
<p v-if="tool.warning" class="corptools-rowwarning">{{ tool.warning }}</p>
<div class="corptools-desc">{{ tool.description }}</div>
</div>
<button
class="btn btn-warning btn-sm"
:disabled="firingTask === tool.task"
@click="confirmRunTask(tool)"
>{{ firingTask === tool.task ? 'Starting...' : 'Run' }}</button>
</div>
</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>
<Modal v-model="showholddialog" :title="holdDialogTitle" size="medium">
<!-- Collapsed by default: an admin who already knows what a hold does
should not have to scroll past an explanation to reach the form, but
the explanation has to be one click away because this switches off
automatic enforcement and that is not self-evident from the title. -->
<details class="hold-explain">
<summary>What this does</summary>
<div class="hold-explain-body">
<!-- Split into two labelled halves because the dialog now performs two
unrelated jobs. Running them together as prose invited the reader
to treat the lock as part of the hold, which is the confusion this
whole feature has to avoid: one expires by itself, one does not. -->
<template v-if="holdableactions.length">
<p class="hold-explain-head">Hold</p>
<p>
ShopDB normally keeps this computer matching its policy. If policy
says a service should be running and someone stops it, ShopDB
starts it again automatically.
</p>
<p>
A hold pauses that automatic correction for the actions you tick,
so you can work on the machine without ShopDB undoing your
changes. It does not change anyone's permissions, and on its own
it does not stop you or anyone else running those actions by hand.
Each one can be released separately afterwards.
</p>
<p>
Give it a time and automatic correction resumes on its own when
that runs out - you do not have to remember to turn it back on.
Set the time to <strong>0</strong> and it never resumes by itself:
the hold stands until someone releases it here, and nothing will
prompt anyone to do that.
</p>
</template>
<p class="hold-explain-head">Lock</p>
<p>
Locking is separate, and it is about PEOPLE rather than policy. A
hold stops ShopDB correcting the machine; a lock stops other users
touching it. While a lock stands, nobody but an admin can run any
action against this computer - they are refused and shown your
reason instead.
</p>
<p>
A lock never expires, so use it when the machine must be left alone
for reasons ShopDB cannot see - mid-repair, a validated part
running, an audit in progress. It stays until an admin unlocks it
from this page. Agent check-in and monitoring keep working
throughout; you do not go blind on the machine.
</p>
</div>
</details>
<!-- Only the HOLD half depends on policy enforcement existing. Locking is
a caller-authority action with no relationship to policy, so it must
stay reachable on an endpoint with nothing to reconcile - which is
most of them. -->
<div v-if="holdableactions.length === 0" class="hold-empty">
<p>
Nothing on this computer is under automatic policy enforcement right
now, so there is nothing for a hold to pause. You can still lock it
below.
</p>
</div>
<template v-if="holdableactions.length">
<div class="form-group">
<label>Actions to pause</label>
<div class="hold-action-list">
<label v-for="entry in holdableactions" :key="entry.action"
class="hold-action-item"
:class="{ 'is-held': entry.held }">
<input type="checkbox" :value="entry.action"
v-model="holdactions" :disabled="entry.held" />
<span class="hold-action-name">{{ entry.action }}</span>
<span v-if="entry.held" class="hold-action-note">
already held
</span>
</label>
</div>
<small class="form-hint">
Pick as many as you need. They all share the reason and duration
below, and each can be released on its own afterwards.
</small>
</div>
</template>
<!-- Outside the branch above: the reason is required by BOTH the hold and
the lock, and it is the text a blocked user reads. -->
<div class="form-group">
<label for="holdreason">Reason</label>
<input id="holdreason" v-model="holdreason" class="form-control"
placeholder="Why this needs to be paused or locked" />
<small class="form-hint">
Recorded against your account and shown to anyone viewing this
computer.
</small>
</div>
<template v-if="holdableactions.length">
<div class="form-group">
<label for="holdminutes">Pause for (minutes)</label>
<input id="holdminutes" v-model.number="holdminutes" type="number"
min="0" step="1" class="form-control" />
<small class="form-hint">
Any length you need. Policy resumes on its own when the time runs
out, and you can release it sooner from this page.
</small>
<!-- The only warning an indefinite hold ever gets: nothing else in
the UI reports a stale one, so it has to be said at the moment
the choice is made. -->
<p v-if="holdisindefinite" class="hold-indefinite-warn">
<strong>0 means indefinite.</strong>
Policy enforcement for the actions above stays switched off until
someone releases it by hand. Nothing will remind you: the computer
will keep reporting healthy while it drifts out of policy.
</p>
</div>
</template>
<!-- Separate concept, offered here because it is the same moment: an
admin about to work on a machine usually wants BOTH the reconciler
off their back and other people's hands off the box. Worded as
"lock" rather than "hold" on purpose - the hold above expires on
its own, this does not, and calling both a hold would teach people
to expect an expiry that never comes. -->
<div class="form-group hold-lock-option">
<label class="hold-lock-check">
<input type="checkbox" v-model="holdlockendpoint" />
<span>Also lock this endpoint</span>
</label>
<small class="form-hint">
Blocks other users from running any action on this computer until an
admin unlocks it. They are shown your reason above. This does not
expire on its own.
</small>
</div>
<template #footer>
<!-- A disabled submit button is indistinguishable from a broken one:
the click is swallowed with no error and no change, so the user
concludes the feature is broken rather than that they missed a
field. Say which field is missing, at the point of the click. -->
<span v-if="holdBlockedReason" class="hold-blocked-reason">
{{ holdBlockedReason }}
</span>
<button class="btn btn-secondary" @click="showholddialog = false">
Cancel
</button>
<button class="btn btn-primary"
:disabled="!canSubmitHold"
@click="submitHold">
{{ holdSubmitLabel }}
</button>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { useRoute, useRouter } from 'vue-router'
import { computersApi, applicationsApi, endpointsApi } from '@/api'
import { withBase } from '@/utils/basePath'
import { useWarrantyBadge } from '@/composables/warrantyBadge'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import AssetTriageBand from '@/components/AssetTriageBand.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetDetailTabs from '@/components/AssetDetailTabs.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'
import Modal from '@/components/Modal.vue'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { Copy } from 'lucide-vue-next'
import { getEntraDeviceUrls } from '@/utils/siteSettings'
import { setRecordTitle } from '@/composables/pageTitle'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const authStore = useAuthStore()
const { isEnabled } = useIdentifierFlags()
// Return-navigation origin. Fleet opens this page with ?from=fleet so Back,
// Edit, and Retire all lead back to Fleet; every other entry path falls back
// to the PCs list.
const fromFleet = computed(() => route.query.from === 'fleet')
const fromQuery = computed(() => (fromFleet.value ? '?from=fleet' : ''))
const returnTo = computed(() => (fromFleet.value ? '/endpoints' : '/pcs'))
const returnLabel = computed(() => (fromFleet.value ? 'Back to Fleet' : 'Back to List'))
const loading = ref(true)
const computer = ref(null)
// Liveness for the triage band. Null when the endpoints plugin is absent, or
// when the viewer lacks endpoints.view - in either case the chip is simply not
// shown, which is better than rendering "unknown" and implying a fault.
const liveness = ref(null)
const installedApps = ref([])
const assetPanels = ref(null)
// Which tab is showing. Always opens on Details: it holds what the asset IS,
// which is the answer somebody arriving from a search or a link came for.
const activegroup = ref('details')
// Azure portal deep links, built from templates a site configures. Empty
// templates mean no link at all, which is the default: the host differs per
// tenant (this site is on the US Government cloud) and Microsoft moves these
// blade paths, so guessing one would send people to a dead page.
const entraUrlTemplate = ref('')
const intuneUrlTemplate = ref('')
const lapsUrlTemplate = ref('')
// The templates arrive from an async settings fetch. Until that settles the
// block stays hidden: rendering the ids first and popping the Intune/Entra
// buttons in a moment later reads as the buttons being missing, which is
// exactly what it looked like before a manual refresh warmed the cache.
const portalTemplatesLoaded = ref(false)
// The settings util negative-caches a failed load for 30s, so an immediate
// retry would just re-read the same empty result. Wait past that window.
const PORTAL_RETRY_MS = 31000
let portalRetryTimer = null
async function loadPortalTemplates({ allowRetry = true } = {}) {
try {
const { entraUrl, intuneUrl, lapsUrl, loaded } = await getEntraDeviceUrls()
if (!loaded && allowRetry) {
// Settings did not load. Leave the block hidden rather than paint a
// wrong "not configured" state, and try again once.
portalRetryTimer = setTimeout(() => {
portalRetryTimer = null
loadPortalTemplates({ allowRetry: false })
}, PORTAL_RETRY_MS)
return
}
entraUrlTemplate.value = entraUrl
intuneUrlTemplate.value = intuneUrl
lapsUrlTemplate.value = lapsUrl
} catch (err) {
// Ignore: templates stay empty and the block falls back to whatever else
// it has rather than hiding forever.
} finally {
// Only settle once the answer is trustworthy, or after the retry is spent.
if (!portalRetryTimer) portalTemplatesLoaded.value = true
}
}
onUnmounted(() => {
if (portalRetryTimer) clearTimeout(portalRetryTimer)
})
function fillTemplate(template, token, value) {
if (!template || !value) return null
return template.split(token).join(encodeURIComponent(value))
}
const entraDeviceLink = computed(() => fillTemplate(
entraUrlTemplate.value, '{objectid}', computer.value?.computer?.azureobjectid))
const intuneDeviceLink = computed(() => fillTemplate(
intuneUrlTemplate.value, '{deviceid}', computer.value?.computer?.azuredeviceid))
// LAPS is a blade on the same device as Entra, so it keys off the OBJECT id.
const lapsDeviceLink = computed(() => fillTemplate(
lapsUrlTemplate.value, '{objectid}', computer.value?.computer?.azureobjectid))
// clipboard.writeText needs a secure context: it is undefined over plain HTTP
// and rejects when the tab is not focused. Say copy failed and leave the id
// selectable rather than reporting a success that did not happen.
async function copyId(value, label) {
if (!value) return
try {
await navigator.clipboard.writeText(value)
toast.success(`${label} copied.`)
} catch (err) {
toast.error(`Copy failed - select the ${label} and copy manually.`)
}
}
// Whether to show the header's cloud block at all.
//
// Gated on the site having CONFIGURED a portal template, not on this PC having
// an id. A site that does not use Intune/Entra has no templates, so the block
// is absent entirely rather than permanently asking for an id it will never
// get. Where templates exist, a PC missing an id shows which one is missing -
// that is the actionable case the empty state was requested for.
const hasCloudIdentity = computed(() => portalTemplatesLoaded.value && Boolean(
entraUrlTemplate.value || intuneUrlTemplate.value || lapsUrlTemplate.value
|| computer.value?.computer?.azuredevicecategory))
// Only offer a tab that has something in it. An empty tab is worse than an
// absent one - it reads as broken rather than as "this site does not collect
// that". Details always shows because core fills it regardless of plugins.
//
// populatedGroups comes from PluginAssetPanels, which knows which panels
// actually returned data; Software is additionally true when core has an
// installed-applications list of its own.
// Gates the Corporate Tools tab, which runs scheduledtask_run. That action now
// has its own grant (endpoints.corptools) instead of riding the old
// endpoints.deploy bundle, so seeing this tab no longer implies the ability to
// push installers or run arbitrary scripts.
// Declared ahead of detailtabs because the watch below evaluates that computed
// during setup, which reads canDeploy before this line would otherwise run.
const canDeploy = computed(() => authStore.hasPermission('endpoints.corptools'))
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' })
// Corporate Tools is permission-gated rather than data-gated: it is a set of
// fixed actions, not a rendering of collected telemetry, so there is no
// populatedGroup to key off. Hidden without endpoints.corptools because every
// button here is an elevated command; the server re-checks regardless.
if (canDeploy.value && liveness.value) {
tabs.push({ key: 'corptools', label: 'Corporate Tools' })
}
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'))
// Break-glass is admin-only and re-checked server-side against the CALLER, so a
// scoped token cannot inherit it from the admin who minted it. This flag only
// decides whether we offer the prompt.
const canOverridePolicy = computed(() => authStore.isAdmin)
// Active holds are shown to everyone with endpoints.view: a held action means
// this PC is deliberately not being reconciled, and an operator who cannot see
// that is left debugging drift that someone already authorized.
const policyholds = ref([])
// Placing or releasing a hold is the same authority as break-glass, so it is
// admin-gated here and re-checked server-side against the CALLER.
const canHoldPolicy = computed(() => authStore.isAdmin)
const showholddialog = ref(false)
const holdableactions = ref([])
const holdactions = ref([])
const holdreason = ref('')
const holdminutes = ref(240)
const holdbusy = ref(false)
const holdlockendpoint = ref(false)
// Drives the warning panel. Guards against the empty input, which v-model.number
// yields as '' rather than 0 - treating that as indefinite would warn while the
// field looks blank, and worse, submit as indefinite.
const holdisindefinite = computed(() => holdminutes.value === 0)
// Null when unlocked. Loaded for EVERY viewer, not just admins, so a blocked
// operator can read who locked the box instead of guessing at a refusal.
const endpointlock = ref(null)
const lockbusy = ref(false)
// The dialog does two independent jobs, and on an endpoint with no policy
// enforcement only the lock half is available. Submitting needs a reason plus
// at least one job selected - otherwise the button would post nothing.
const canSubmitHold = computed(() => {
if (holdbusy.value || !holdreason.value.trim()) return false
// An empty duration is not a valid hold: it is neither a window nor an
// explicit indefinite choice, so block rather than guess which was meant.
if (holdactions.value.length && !Number.isInteger(holdminutes.value)) {
return false
}
return holdactions.value.length > 0 || holdlockendpoint.value
})
// Mirrors canSubmitHold in the same order, so the message always names the
// first thing actually standing in the way. Empty when the form is submittable.
const holdBlockedReason = computed(() => {
if (holdbusy.value) return ''
if (!holdreason.value.trim()) return 'Enter a reason to continue.'
if (!holdactions.value.length && !holdlockendpoint.value) {
return holdableactions.value.length
? 'Select an action to pause, or tick "Also lock this endpoint".'
: 'Tick "Also lock this endpoint" to continue.'
}
if (holdactions.value.length && !Number.isInteger(holdminutes.value)) {
return 'Enter a duration in minutes, or 0 for indefinite.'
}
return ''
})
const holdSubmitLabel = computed(() => {
if (holdbusy.value) return 'Working...'
const count = holdactions.value.length
if (count && holdlockendpoint.value) {
return count > 1 ? `Place ${count} holds and lock` : 'Place hold and lock'
}
if (holdlockendpoint.value) return 'Lock endpoint'
return count > 1 ? `Place ${count} holds` : 'Place hold'
})
// Named for what the dialog can actually do here. Calling it "Place admin hold"
// on an endpoint with nothing to hold reads as a dead end.
const holdDialogTitle = computed(() =>
holdableactions.value.length ? 'Place admin hold' : 'Lock endpoint')
async function openHoldDialog() {
holdactions.value = []
holdreason.value = ''
holdminutes.value = 240
holdableactions.value = []
// Default off: locking everyone else out is the bigger side effect of the two
// and should be a deliberate tick, not something inherited from last time.
holdlockendpoint.value = false
showholddialog.value = true
try {
const response = await endpointsApi.listHoldableActions(computer.value.assetid)
holdableactions.value = response.data?.data || []
} catch (error) {
// Leave the list empty: the dialog then explains there is nothing under
// enforcement, which is the same thing the user needs to know either way.
holdableactions.value = []
}
}
async function submitHold() {
if (!canSubmitHold.value) return
const reason = holdreason.value.trim()
const actions = [...holdactions.value]
const wantshold = actions.length > 0
const wantslock = holdlockendpoint.value
// Phrased once here so every branch below agrees on how the holds are named.
const summary = actions.length === 1
? actions[0]
: `${actions.length} actions`
holdbusy.value = true
try {
if (wantshold) {
// One call for every action: the server applies them atomically, so a
// rejected name leaves no partial set of holds behind.
await endpointsApi.placePolicyHold(
computer.value.assetid, actions, reason, holdminutes.value)
}
// Sequenced after the hold, not in parallel: if the lock call fails the
// hold still stands and the banner says so. Firing both at once could
// leave the page reporting a lock whose hold never landed.
if (wantslock) {
await endpointsApi.placeEndpointLock(computer.value.assetid, reason)
}
if (wantshold && wantslock) {
toast.success(
`Policy enforcement paused for ${summary} and endpoint locked`)
} else if (wantslock) {
toast.success('Endpoint locked')
} else {
toast.success(`Policy enforcement paused for ${summary}`)
}
showholddialog.value = false
await loadPolicyHolds()
await loadEndpointLock()
} catch (error) {
toast.error(apiError(error, wantshold
? 'Failed to place hold'
: 'Failed to lock endpoint'))
} finally {
holdbusy.value = false
}
}
async function confirmReleaseHold(hold) {
if (!hold) return
if (!confirm(`Resume automatic policy enforcement for ${hold.action}?\n\n`
+ 'ShopDB will go back to correcting this action automatically, '
+ 'which may undo changes made while the hold was in place.')) return
holdbusy.value = true
try {
await endpointsApi.releasePolicyHold(computer.value.assetid, hold.action)
toast.success(`Policy enforcement resumed for ${hold.action}`)
await loadPolicyHolds()
} catch (error) {
toast.error(apiError(error, 'Failed to release hold'))
} finally {
holdbusy.value = false
}
}
async function loadPolicyHolds() {
if (!computer.value?.assetid) return
try {
const response = await endpointsApi.listPolicyHolds(computer.value.assetid)
policyholds.value = response.data?.data || []
} catch {
// Non-fatal: the holds banner is context, not a gate. A failure here must
// not blank the page or block the actions below it.
policyholds.value = []
}
}
async function loadEndpointLock() {
if (!computer.value?.assetid) return
try {
const response = await endpointsApi.getEndpointLock(computer.value.assetid)
endpointlock.value = response.data?.data || null
} catch {
// Same reasoning as the holds banner: context, not a gate. The server is
// the real enforcement point, so a failed read here cannot let anyone past
// a lock - it only means this page does not explain one.
endpointlock.value = null
}
}
async function confirmReleaseLock() {
if (!endpointlock.value) return
if (!confirm('Unlock this endpoint?\n\n'
+ 'Other users will be able to run actions against it again. '
+ 'Any policy holds you placed stay in force until they expire '
+ 'or you release them separately.')) return
lockbusy.value = true
try {
await endpointsApi.releaseEndpointLock(computer.value.assetid)
toast.success('Endpoint unlocked')
await loadEndpointLock()
} catch (error) {
toast.error(apiError(error, 'Failed to unlock endpoint'))
} finally {
lockbusy.value = false
}
}
function holdExpiryLabel(hold) {
if (!hold.expiresat) return 'unknown'
// Server sends naive UTC isoformat; mark it as UTC so it renders in local time
// rather than being read as local and shifting by the offset.
const raw = hold.expiresat.endsWith('Z') ? hold.expiresat : `${hold.expiresat}Z`
const when = new Date(raw)
return Number.isNaN(when.getTime()) ? 'unknown' : when.toLocaleString()
}
// Warranty fetch + hero badge (shared across asset detail pages).
const { heroWarranty, warrantyDate: formatWarrantyDate } = useWarrantyBadge(() => computer.value?.assetid)
// relationships render via the shared AssetRelationships card
async function loadLiveness(assetid) {
if (!assetid) return
try {
const response = await endpointsApi.get(assetid)
const state = response.data.data || {}
// The SERVER decides reporting/quiet/never, because the quiet window is a
// site setting. Guessing a threshold here would disagree with the Fleet
// list about the same endpoint.
const labels = {
reporting: `Reporting, ${relativeAge(state.lastreportedat)}`,
quiet: `Quiet, last seen ${relativeAge(state.lastreportedat)}`,
never: 'Never reported'
}
const levels = { reporting: 'ok', quiet: 'warning', never: 'unknown' }
if (state.status) {
liveness.value = { level: levels[state.status], label: labels[state.status] }
}
} catch (error) {
// 404 = no agent on this PC, 403 = the viewer cannot read telemetry.
// Neither is a page error; the chip just does not appear.
liveness.value = null
}
}
function relativeAge(value) {
if (!value) return 'never'
const then = new Date(value)
if (isNaN(then)) return 'unknown'
const mins = Math.floor((Date.now() - then.getTime()) / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins} m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours} h ago`
return `${Math.floor(hours / 24)} d ago`
}
onMounted(async () => {
// Non-critical: a site that has not configured these just gets no link, so a
// failure here must not stop the page loading.
//
// A failed settings fetch is negative-cached for 30s and resolves EMPTY, which
// is indistinguishable from "not configured" by value alone - the block would
// hide itself and only appear on a later visit that happened to land after the
// window. Retry once past that window instead of showing a wrong empty state.
loadPortalTemplates()
try {
const response = await computersApi.get(route.params.id)
computer.value = response.data.data
setRecordTitle(computer.value?.assetnumber || computer.value?.name)
// Load installed applications
try {
const appsResponse = await applicationsApi.getMachineApps(route.params.id)
installedApps.value = appsResponse.data.data || []
} catch (appError) {
}
loadLiveness(computer.value?.assetid)
loadPolicyHolds()
loadEndpointLock()
// Warranties load via useWarrantyBadge (watches computer.assetid).
} catch (error) {
console.error('Error loading computer:', error)
} finally {
loading.value = false
}
})
function getStatusClass(status) {
if (!status) return 'badge-info'
const s = status.toLowerCase()
if (s === 'in use' || s === 'active') return 'badge-success'
if (s === 'in repair') return 'badge-warning'
if (s === 'retired') return 'badge-danger'
return 'badge-info'
}
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
// Force a live telemetry cycle rather than re-reading the cached snapshot.
// Enqueues collect_now (the agent reports off-cadence), waits for the agent to
// finish it, then re-fetches the panels so the cards reflect the endpoint's
// real current state (e.g. a process the operator just closed).
async function refreshStatus() {
if (refreshing.value) return
refreshing.value = true
try {
// Baseline the current telemetry timestamp BEFORE forcing a collect. A
// command reaching 'succeeded' only means the agent ran collect_now, not
// that it has posted the fresh snapshot yet, so we additionally wait for
// lastreportedat to move past this baseline before re-rendering the panels.
const baseline = await currentTelemetryStamp()
const response = await endpointsApi.enqueueCommand(computer.value.assetid, 'collect_now')
const commandId = response?.data?.data?.endpointcommandid
const outcome = await waitForCommand(commandId)
const fresh = outcome === 'succeeded'
? await waitForFreshTelemetry(baseline)
: false
await assetPanels.value?.refresh()
// The liveness chip reads its own endpoint, so refreshing the panels alone
// leaves it showing the age it was rendered with at page load - the button
// looked like it had done nothing. Re-read it whatever the outcome: a
// timeout or failure still moves the chip toward quiet, which is the
// honest reading.
await loadLiveness(computer.value?.assetid)
if (outcome === 'timeout') {
toast.info('Refresh queued; the endpoint agent may be offline or busy')
} else if (outcome === 'failed') {
toast.error('The endpoint agent could not refresh telemetry')
} else if (!fresh) {
toast.info('Refresh sent; the endpoint has not reported new data yet')
} else {
toast.success('Status refreshed')
}
} catch (error) {
toast.error(apiError(error, 'Failed to refresh status'))
} finally {
refreshing.value = false
}
}
// Read the endpoint's current lastreportedat (ISO string or null). Used as the
// freshness baseline; a null baseline means the box has never reported, so any
// non-null stamp later counts as fresh.
async function currentTelemetryStamp() {
try {
const response = await endpointsApi.telemetryMeta(computer.value.assetid)
return response?.data?.data?.lastreportedat || null
} catch {
return null
}
}
// Poll lastreportedat until it advances past the baseline (proof a new snapshot
// landed) or the attempt budget runs out. Returns true only on a confirmed
// newer stamp; a bounded false lets the caller still refresh and message that
// the box has not reported yet, rather than hanging the button on an idle agent.
async function waitForFreshTelemetry(baseline, { attempts = 15, delayMs = 1000 } = {}) {
const baseMs = baseline ? Date.parse(baseline) : 0
for (let i = 0; i < attempts; i += 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
const stamp = await currentTelemetryStamp()
if (stamp && Date.parse(stamp) > baseMs) return true
}
return false
}
// Poll the endpoint's recent commands until the given command reaches a terminal
// state or we give up. Returns 'succeeded' | 'failed' | 'timeout'. The agent
// long-polls, so a live box turns this around in a couple of seconds; an offline
// box lets it time out cleanly rather than hanging the button forever.
async function waitForCommand(commandId, { attempts = 15, delayMs = 1000 } = {}) {
if (!commandId) return 'timeout'
for (let i = 0; i < attempts; i += 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
try {
const response = await endpointsApi.listCommands(computer.value.assetid)
const command = (response?.data?.data || []).find(
(row) => row.endpointcommandid === commandId)
if (command && command.status === 'succeeded') return 'succeeded'
if (command && command.status === 'failed') return 'failed'
} catch {
// Transient poll failure: keep trying until the attempt budget runs out.
}
}
return 'timeout'
}
// A policy denial is the server telling us this action is not exposed on this
// endpoint. For an admin that is recoverable, but only deliberately: we surface
// what was refused, demand a written reason, and send it as an explicit
// override. Returns true when the retry succeeded.
async function retryWithOverride(assetid, action, target, error, label) {
if (!canOverridePolicy.value) return false
const message = apiError(error, '')
if (!message.includes('not permitted by policy')) return false
const reason = prompt(
`Policy does not permit ${action} on ${label}.\n\n`
+ 'As an admin you may override this. The override is recorded against '
+ 'your account, and desired-state enforcement for this action is paused '
+ 'until the hold expires.\n\n'
+ 'Reason for the override:')
// Cancel returns null; an empty or whitespace reason is refused server-side
// anyway, so do not spend a round trip on it.
if (!reason || !reason.trim()) return false
await endpointsApi.enqueueCommand(assetid, action, target, {
policyoverride: true,
overridereason: reason.trim(),
})
await loadPolicyHolds()
return true
}
// 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) {
try {
const overridden = await retryWithOverride(
computer.value.assetid, 'system_restart', null, error, label)
if (overridden) {
toast.success(`Restart queued for ${label} (policy overridden)`)
return
}
toast.error(apiError(error, 'Failed to queue restart'))
} catch (retryError) {
toast.error(apiError(retryError, 'Failed to queue restart'))
}
} finally {
restarting.value = false
}
}
// Fixed set, deliberately not user-editable from this page: these are the task
// paths the agent ships as its approved list (ScheduledTaskPolicy). Adding one
// here without adding it to the agent's allowlist gets a clean rejection from
// the endpoint rather than an execution, which is the intended failure mode.
const corporateTools = [
{
task: '\\ReportIP',
label: 'Report IP',
description: 'Re-runs the corporate IP reporting agent on this computer.',
},
{
task: '\\SFLD-ApplyDSCConfig',
label: 'Apply DSC Configuration',
description: 'Re-applies the shop floor DSC baseline. Runs as SYSTEM and '
+ 'may change system configuration.',
},
{
task: '\\SFLD - Consume Credentials',
label: 'Consume Credentials',
description: 'Runs the credential consumption task in the logged-on '
+ 'user\'s session. Requires a user to be signed in.',
},
// Dell Command | Update, split into two entries because they are two
// utilities with very different consequences: the scan only reports, the
// update installs and may reboot. Keeping them as separate buttons means the
// destructive one is never a field edit away from the safe one.
{
task: '\\ShopDB-DellCommandScan',
label: 'Dell Command: Scan',
description: 'Scans for available Dell driver and firmware updates. '
+ 'Reports only - installs nothing and does not reboot.',
},
{
task: '\\ShopDB-DellCommandUpdate',
label: 'Dell Command: Apply Updates',
// Persistent breakglass warning. Tanium is the primary update utility for
// this fleet; this tool exists only for when Tanium has failed. The text is
// rendered in the card at all times (not behind a hover or the confirm
// dialog) so the operator cannot reach Run without having seen it.
warning: 'These updates are meant to be executed by the primary utility - '
+ 'Tanium. This tool should only be used in the event of a failed '
+ 'update or a problem with Tanium. This is a breakglass update '
+ 'utility to account for critical failures in the primary '
+ 'management solution.',
description: 'Installs available Dell driver and firmware updates. Runs as '
+ 'SYSTEM and MAY REBOOT this computer when it finishes.',
destructive: true,
},
]
const firingTask = ref('')
// Elevated: queues a scheduledtask_run. The confirm gate mirrors
// confirmRestart - these start real work on a live machine and some of them
// change system state - and the server re-checks endpoints.corptools regardless.
//
// Tools marked destructive get a TYPED confirmation rather than an OK button,
// for the same reason Restart has its own gate: applying firmware updates can
// reboot a machine that may be mid-cycle on the shop floor, and a misclick
// should not be able to do that.
async function confirmRunTask(tool) {
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
if (tool.destructive) {
const typed = prompt(`${tool.label} on ${label}\n\n`
+ `${tool.description}\n\n`
+ 'Type RUN to confirm.')
if (typed !== 'RUN') return
} else if (!confirm(`Run "${tool.label}" on ${label}?\n\n`
+ `${tool.description}\n\n`
+ 'The task starts immediately on the endpoint.')) {
return
}
firingTask.value = tool.task
try {
await endpointsApi.enqueueCommand(
computer.value.assetid, 'scheduledtask_run', tool.task)
// "Started", not "finished": the agent reports success as soon as Task
// Scheduler accepts the task, so this never means the work is done. Any
// result the task produces comes back through telemetry, not this command.
toast.success(`${tool.label} started on ${label}`)
} catch (error) {
toast.error(apiError(error, `Failed to queue ${tool.label}`))
} finally {
firingTask.value = ''
}
}
// Retire, not delete: the server deactivates the asset, so the PC
// leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true.
const isRetired = computed(() => computer.value?.isactive === false)
async function restore() {
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
try {
await computersApi.update(route.params.id, { isactive: true })
toast.success(`Restored ${label}`)
const response = await computersApi.get(route.params.id)
computer.value = response.data.data
} catch (error) {
console.error('Error restoring:', error)
toast.error(apiError(error, 'Failed to restore'))
}
}
async function confirmRetire() {
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
+ `History and relationships are kept, and it can be restored.`)) return
try {
await computersApi.delete(route.params.id)
toast.success(`Retired ${label}`)
router.push(returnTo.value)
} catch (error) {
console.error('Error retiring PC:', error)
toast.error(apiError(error, 'Failed to retire PC'))
}
}
</script>
<style scoped>
.retired-banner {
border-left: 3px solid var(--warning);
margin-bottom: 1rem;
}
.portal-link {
font-size: 0.78rem;
margin-left: 0.5rem;
padding: 0.05rem 0.4rem;
border: 1px solid var(--border);
border-radius: 3px;
color: var(--link, var(--primary));
text-decoration: none;
white-space: nowrap;
}
.portal-link:hover { border-color: var(--primary); }
/* PC-only header additions. These class names exist nowhere else in the app -
Machines, Printers and the rest share only .hero-details/.hero-detail-*
from global style.css, which is deliberately left alone. */
.hero-primary {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Identity on the left, liveness and endpoint actions pinned to the right.
.hero-primary is a COLUMN (identity, then the portals row beneath), so the
corner block needs its own row here rather than margin-left:auto against
the column, which would just stack it under the identity. */
.hero-headline {
display: flex;
align-items: flex-start;
gap: 1rem;
}
.hero-headline > .hero-identity { flex: 1 1 auto; }
@media (max-width: 768px) {
.hero-headline { flex-wrap: wrap; }
}
.hero-identity {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0;
}
/* Horizontal, in the same visual language as .hero-details directly above it,
separated by a rule rather than sitting in a box. Wraps to a second line on
a narrow window instead of forcing a scrollbar. */
.hero-portals {
display: flex;
flex-wrap: wrap;
gap: 2rem;
padding-top: 0.85rem;
border-top: 1px solid var(--border);
}
.hero-portal {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
/* The portal button lives on the label line, so it reads as an action on the
id below it and adds no height of its own. */
.hero-portal-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-light);
}
.hero-portal-value {
font-size: 15px;
font-weight: 500;
color: var(--text);
}
/* Full id, never truncated: a half-copied GUID looks complete and is worse
than none. user-select:all keeps manual selection working as a fallback for
when the clipboard API is unavailable over plain HTTP. */
.hero-portal-idrow {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.hero-portal-id {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 13px;
color: var(--text);
user-select: all;
white-space: nowrap;
}
.copy-btn {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.2rem;
line-height: 0;
color: var(--text-light);
background: none;
border: 1px solid var(--border);
border-radius: 0.25rem;
cursor: pointer;
}
/* Liveness + endpoint actions in the header corner. Pinned to the top right of
.hero-primary, which is the flex row holding the identity block. */
.hero-endpoint {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.75rem;
margin-left: auto;
flex: 0 0 auto;
padding-left: 1.5rem;
}
.hero-status {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.45rem;
}
/* A touch larger than the shared hero label (10px in style.css). Scoped to
this one rather than bumping .hero-detail-label, which PC Type / OS /
Location also use and which is sized to sit quietly under the identity. */
.hero-status-label {
font-size: 12px;
}
.hero-endpoint-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
/* Warning-toned, not error-toned: a hold is a deliberate, audited state, not a
fault. It should read as "someone did this on purpose" at a glance. */
.policy-holds {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-top: 0.6rem;
}
/* Heavier than .policy-hold on purpose. A lock outranks a hold - it stops every
non-admin from acting at all - so it must not read as one more amber note in
a stack of them. */
.endpoint-lock {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.6rem;
padding: 0.45rem 0.7rem;
font-size: 0.85rem;
border-left: 3px solid var(--danger);
background: color-mix(in srgb, var(--danger) 12%, transparent);
border-radius: 3px;
}
.endpoint-lock-tag {
font-family: monospace;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--danger);
}
.endpoint-lock-text {
color: var(--text);
}
.endpoint-lock-release {
margin-left: auto;
padding: 0.1rem 0.5rem;
font-size: 0.78rem;
color: var(--danger);
background: transparent;
border: 1px solid var(--danger);
border-radius: 3px;
cursor: pointer;
}
.endpoint-lock-release:hover:not(:disabled) {
background: color-mix(in srgb, var(--danger) 18%, transparent);
}
.endpoint-lock-release:disabled {
opacity: 0.55;
cursor: default;
}
/* Scrolls rather than growing the dialog: endpoints under broad policy can
carry a long action list, and the reason/duration fields below must stay
reachable without hunting for them. */
.hold-action-list {
display: flex;
flex-direction: column;
gap: 0.15rem;
max-height: 13rem;
overflow-y: auto;
padding: 0.4rem 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
}
.hold-action-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
font-weight: 400;
cursor: pointer;
}
.hold-action-item input {
width: auto;
margin: 0;
}
.hold-action-item.is-held {
opacity: 0.6;
cursor: default;
}
.hold-action-name {
font-family: var(--font-mono, monospace);
font-size: 0.9rem;
}
.hold-action-note {
font-size: 0.78rem;
color: var(--text-light);
font-style: italic;
}
/* Deliberately loud. An indefinite hold is the one setting here with no
self-correcting behaviour behind it.
Tinted from --warning via colour-mix rather than a fixed pale hex: a light
cream panel keeps its dark body text in dark mode and turns unreadable. */
.hold-indefinite-warn {
margin: 0.5rem 0 0;
padding: 0.5rem 0.65rem;
border-left: 3px solid var(--warning);
background: color-mix(in srgb, var(--warning) 12%, transparent);
font-size: 0.82rem;
line-height: 1.45;
color: var(--text);
}
/* Separated from the fields above it: this is a second, heavier decision than
the hold being configured, not another attribute of it. */
.hold-lock-option {
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
.hold-lock-check {
display: flex;
align-items: center;
gap: 0.45rem;
font-weight: 600;
cursor: pointer;
}
.hold-lock-check input {
width: auto;
margin: 0;
}
.policy-hold {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.4rem;
font-size: 0.85rem;
padding: 0.35rem 0.6rem;
border-left: 3px solid var(--warning);
background: color-mix(in srgb, var(--warning) 10%, transparent);
border-radius: 3px;
}
.policy-hold-action {
font-family: monospace;
font-weight: 600;
}
.policy-hold-text {
color: var(--text);
}
/* Pushed to the row end so the release affordance reads as belonging to that
specific hold rather than to the banner as a whole. */
.policy-hold-release {
margin-left: auto;
padding: 0.1rem 0.5rem;
font-size: 0.78rem;
color: var(--warning);
background: transparent;
border: 1px solid var(--warning);
border-radius: 3px;
cursor: pointer;
}
.policy-hold-release:hover:not(:disabled) {
background: color-mix(in srgb, var(--warning) 18%, transparent);
}
.policy-hold-release:disabled {
opacity: 0.55;
cursor: default;
}
.hold-explain {
margin-bottom: 1rem;
padding: 0.5rem 0.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
}
.hold-explain summary {
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
}
.hold-explain-body {
margin-top: 0.5rem;
font-size: 0.875rem;
line-height: 1.5;
}
.hold-explain-body p {
margin: 0 0 0.6rem;
}
.hold-explain-body p:last-child {
margin-bottom: 0;
}
/* Labels the two halves apart. Without them the lock paragraphs read as more
detail about the hold, which is the exact conflation to avoid. */
.hold-explain-head {
margin-bottom: 0.25rem !important;
font-weight: 700;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
color: var(--text-light);
}
.hold-explain-head:not(:first-child) {
margin-top: 0.9rem !important;
}
.hold-empty {
font-size: 0.9rem;
color: var(--text-light);
}
/* Sits to the left of the footer buttons, next to the control it explains. */
.hold-blocked-reason {
margin-right: auto;
align-self: center;
text-align: left;
font-size: 0.8rem;
color: var(--text-light);
}
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light);
}
/* The chip styles live in AssetTriageBand's scoped block, so they do not reach
this copy of the markup. Kept in step with that component deliberately. */
.triage-chip {
display: inline-flex;
align-items: center;
font-size: 0.9rem;
padding: 0.15rem 0.6rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
white-space: nowrap;
}
.triage-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 0.4rem;
background: var(--text-light);
}
.triage-chip.ok .triage-dot { background: var(--success); }
.triage-chip.warning .triage-dot { background: var(--warning); }
/* Corporate Tools: a plain action list, not a data table - each row is a
trigger with the consequence stated next to it, so the operator reads what
it does before the confirm gate rather than after. */
.corptools-intro {
margin: 0 0 1rem;
color: var(--text-secondary);
font-size: 0.875rem;
}
/* Warning weight without alert-banner styling: it is permanent, so a loud
dismissible-looking banner would be tuned out within a day. The left rule and
warning tint keep it legible as a standing caution. */
.corptools-warning {
margin: 0 0 1rem;
padding: 0.5rem 0.75rem;
border-left: 3px solid var(--warning, #d97706);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.875rem;
}
.corptools-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.corptools-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
}
.corptools-name {
font-weight: 600;
}
.corptools-desc {
color: var(--text-secondary);
font-size: 0.8125rem;
margin-top: 0.125rem;
}
/* Row-level breakglass notice. Carries the same left-rule treatment as the
card-level warning so it reads as a caution and not as body copy, but sits
inside the row it applies to. */
.corptools-rowwarning {
margin: 0.25rem 0 0.375rem;
padding: 0.375rem 0.625rem;
border-left: 3px solid var(--danger, #dc2626);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.8125rem;
font-weight: 500;
}
.triage-chip.critical .triage-dot { background: var(--danger); }
.triage-chip.ok { color: var(--success-dark); }
.triage-chip.warning { color: var(--warning-dark); }
.triage-chip.critical { color: var(--danger); }
[data-theme="dark"] .triage-chip.ok { color: var(--success); }
[data-theme="dark"] .triage-chip.warning { color: var(--warning); }
/* The Details tab flows its cards instead of sitting on grid rows.
Grid pairs items into implicit rows, so a row is as tall as its tallest
card and align-items:start leaves the shorter one with dead space beneath
it - Warranty is short, Endpoint Status is tall, and Policy has to wait for
the taller of the two instead of sliding up. Multicol has no rows, so each
card starts where the previous one ended and the holes close.
Multicol was rejected for .content-grid globally because a column-span:all
child (.pap-span, used by table panels) overlaps the shorter column - see
the note by .content-grid in style.css. That cannot happen here: this tab's
panels are all keyvalue/list renders and spansFullWidth() only returns true
for table (or a tabs panel holding one), so nothing in this subtree spans.
Monitors and Service/Process DO carry table panels and stay on grid.
Pure CSS, so there is no measuring, no observer and no reflow cost - the
JS-masonry option was declined for exactly that reason.
The tradeoff multicol brings is reading order: cards run down the left
column then down the right, so DOM order still matches visual order here
(it is the same top-to-bottom-per-column flow), and column-count keeps
focus order intact because no item is visually reordered across columns. */
.content-grid.details-flow {
display: block;
column-count: 2;
column-gap: 25px;
}
/* display:contents on .content-column would leave the wrappers invisible to
multicol as well, but the cards inside them need the break rule, so target
the cards themselves wherever they sit. :deep() because the plugin panels
are rendered by PluginAssetPanels and carry that component's scope id. */
.content-grid.details-flow :deep(.section-card) {
break-inside: avoid;
/* A card split across the column break would show its header in one column
and its rows in the other. */
-webkit-column-break-inside: avoid;
page-break-inside: avoid;
/* Multicol has no gap between stacked items the way grid does, so the
spacing that grid's gap supplied has to come back as a margin. */
margin-bottom: 25px;
}
/* One column below the hero breakpoint, matching .content-grid's own
single-column collapse so the two tabs behave the same on a phone. */
@media (max-width: 768px) {
.content-grid.details-flow {
column-count: 1;
}
}
/* Restart is the heaviest action on the page and sat visually equal to the
reference chips beside it. ~25% up from .btn-sm (10px / 6px 15px) so the
weight matches the consequence. Scoped with :deep() because the button is
passed into AssetTriageBand's slot, so the band's scope id lands on it. */
:deep(.triage-actions .btn-warning) {
font-size: 12.5px;
padding: 7.5px 19px;
}
/* Below the hero's two-column breakpoint the corner block would strand the
buttons against the right edge of a narrow screen. */
@media (max-width: 768px) {
.hero-endpoint {
margin-left: 0;
align-items: flex-start;
width: 100%;
}
.hero-status { align-items: flex-start; }
.hero-endpoint-actions { justify-content: flex-start; }
}
.copy-btn:hover {
color: var(--primary);
border-color: var(--primary);
}
.hero-portal-missing {
font-size: 13px;
color: var(--text-light);
font-style: italic;
}
@media (max-width: 900px) {
.hero-portals { gap: 1rem 1.5rem; }
/* Only here does the id need to break: at this width it cannot fit a line. */
.hero-portal-id { white-space: normal; overflow-wrap: anywhere; }
}
/* PC-specific styles - shared styles are in global style.css */
/* Remote-access protocol links */
.access-methods {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.access-methods .muted {
color: var(--text-light);
}
.access-link {
display: inline-block;
padding: 3px 12px;
margin: 0 6px 4px 0;
border-radius: 14px;
background: var(--primary);
color: #fff;
font-size: 0.82rem;
font-weight: 600;
text-decoration: none;
}
.access-link:hover {
background: var(--primary-dark);
text-decoration: none;
}
.access-link.disabled {
background: var(--secondary);
opacity: 0.5;
cursor: not-allowed;
}
/* Installed Applications */
.app-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.app-item {
display: block;
padding: 1rem;
background: var(--bg);
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: background 0.15s;
}
.app-item:hover {
background: var(--border);
text-decoration: none;
}
.app-info {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.app-name {
font-weight: 500;
font-size: 1.125rem;
color: var(--text);
}
.app-version {
font-size: 1rem;
color: var(--text-light);
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.app-desc {
margin-top: 0.375rem;
font-size: 1rem;
color: var(--text-light);
}
/* Network details */
.network-details {
margin-top: 0.5rem;
display: flex;
gap: 1.25rem;
font-size: 1rem;
color: var(--text-light);
}
/* Network card styles removed with the card itself - IP History and IP
Configuration are plugin panels and carry their own table styling. */
.muted {
color: var(--text-light);
margin: 0;
}
</style>