A run_script command was judged solely on PowerShell's exit code, and PowerShell treats most errors as non-terminating: it writes the error, runs the next line, and still exits zero. The account-provisioning script reported succeeded having provisioned nothing, and because no output was captured there was no evidence to contradict the verdict. Scripts now carry an errormode. Strict prepends $ErrorActionPreference = 'Stop' so the first error ends the run and the exit code means what it says. It is opt-in per script rather than global because bodies authored against the permissive semantics rely on them - tolerating a failing cleanup, probing with a cmdlet that errors when a thing is absent, looping past a bad item - and would start aborting midway, leaving endpoints half-configured. The default is unchanged, so every existing script keeps running exactly as before. The prologue is applied after digest verification, so the SHA-256 still validates the bytes the server delivered. stdout/stderr is captured on both paths: redirected streams for SYSTEM, a transcript file under the ShopFloor profile for user-session runs, since CreateProcessAsUser cannot inherit the service's pipes across the session boundary. Output is always logged locally and returned to the server only on failure, so routine output stays on the machine that produced it instead of filling command history. Also adds the optional per-script working directory, which the same surfaces needed, and a collapsible explainer on the scripts page - the dropdown is not self-explanatory, and an operator who does not know about non-terminating errors has no reason to ever leave the default. Agent 1.14.0. An older agent ignoring errormode keeps today's behaviour.
2283 lines
92 KiB
Vue
2283 lines
92 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>
|
|
|
|
<!-- Run Scripts: pushes a ShopDB-hosted script from the library. Distinct
|
|
from Corporate Tools above - that starts a task the endpoint already
|
|
approved, this delivers script content ShopDB holds. Separate grant
|
|
(endpoints.runscript), separate tab, so neither implies the other. -->
|
|
<div v-show="activegroup === 'runscripts'" class="detail-full">
|
|
<div class="card">
|
|
<h3>Run Scripts</h3>
|
|
<p class="corptools-intro">
|
|
Sends a script from the ShopDB script library to this computer. The
|
|
agent fetches the body by digest and runs it in the context the
|
|
script itself specifies.
|
|
</p>
|
|
<!-- Persistent, same reasoning as the Corporate Tools warning: this
|
|
is arbitrary code on a live machine, and the operator must still
|
|
be able to see that when they decide to press Run. -->
|
|
<p class="corptools-warning">
|
|
A script here runs as SYSTEM unless it is configured to run as the
|
|
logged-on user. It takes effect immediately on the live computer and
|
|
cannot be undone from here.
|
|
</p>
|
|
|
|
<div v-if="scriptsloading" class="runscripts-empty">Loading scripts...</div>
|
|
<div v-else-if="runnablescripts.length === 0" class="runscripts-empty">
|
|
No runnable scripts. A script needs an uploaded body before it can be
|
|
sent; add one under Settings then return here.
|
|
</div>
|
|
<template v-else>
|
|
<div class="form-group">
|
|
<label for="runscript-picker">Script</label>
|
|
<select
|
|
id="runscript-picker"
|
|
v-model="selectedscriptid"
|
|
class="form-control"
|
|
>
|
|
<option value="">Select a script...</option>
|
|
<option
|
|
v-for="script in runnablescripts"
|
|
:key="script.scriptid"
|
|
:value="script.scriptid"
|
|
>{{ script.scriptname }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- What the operator is about to run, stated before they run it.
|
|
Run context and working directory are authoring decisions made
|
|
in the library and are shown read-only here: overriding either
|
|
at push time would mean the script that ran is not the script
|
|
that was reviewed. -->
|
|
<div v-if="selectedscript" class="runscripts-detail">
|
|
<p v-if="selectedscript.scriptdescription" class="runscripts-desc">
|
|
{{ selectedscript.scriptdescription }}
|
|
</p>
|
|
<dl class="runscripts-facts">
|
|
<dt>Runs as</dt>
|
|
<dd>
|
|
{{ selectedscript.runcontext === 'user'
|
|
? 'The logged-on user' : 'SYSTEM' }}
|
|
</dd>
|
|
<dt>Runs in</dt>
|
|
<dd class="mono">
|
|
{{ selectedscript.workingdirectory || 'Agent staging folder' }}
|
|
</dd>
|
|
</dl>
|
|
<p
|
|
v-if="selectedscript.workingdirectory"
|
|
class="runscripts-note"
|
|
>
|
|
This script requires that folder to exist on the computer. If it
|
|
does not, the run fails and reports the missing folder rather
|
|
than running somewhere else.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="runscripts-actions">
|
|
<button
|
|
class="btn btn-warning"
|
|
:disabled="!selectedscript || firingScript"
|
|
@click="confirmRunScript"
|
|
>{{ firingScript ? 'Sending...' : 'Run Script' }}</button>
|
|
</div>
|
|
</template>
|
|
</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, scriptsApi } 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'))
|
|
|
|
// Gates the Run Scripts tab. Deliberately NOT canDeploy: endpoints.corptools
|
|
// starts a task the endpoint already holds and has allowlisted, while
|
|
// endpoints.runscript delivers arbitrary PowerShell that runs as SYSTEM unless
|
|
// the script pins a user context. Those were split out of the old
|
|
// endpoints.deploy bundle for exactly this reason (see
|
|
// ELEVATED_ACTION_PERMISSIONS in plugins/endpoints/commands.py), so hanging
|
|
// scripts off the Corporate Tools gate would put the bundle back together in
|
|
// the UI after the backend took it apart.
|
|
const canRunScript = computed(() => authStore.hasPermission('endpoints.runscript'))
|
|
|
|
// Operator report screenshots are an uncontrolled capture of whatever was on
|
|
// the operator's screen, so they carry their own grant rather than riding
|
|
// endpoints.view. Declared alongside canDeploy for the same reason: detailtabs
|
|
// reads it during setup.
|
|
const canViewOperatorReports = computed(
|
|
() => authStore.hasPermission('endpoints.operatorreports'))
|
|
|
|
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.
|
|
// Permission-gated as well as data-gated: the screenshots can carry shopfloor
|
|
// content that endpoints.view does not imply a right to see. The panel and
|
|
// image routes both re-check server-side; this only hides the tab.
|
|
if (has('reports') && canViewOperatorReports.value) {
|
|
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' })
|
|
}
|
|
// Run Scripts is its own tab rather than a card in Corporate Tools because it
|
|
// is a different grant and a different trust model - a library of arbitrary
|
|
// PowerShell delivered by ShopDB, versus a closed set of tasks the endpoint
|
|
// already approved. Liveness-gated for the same reason as Corporate Tools:
|
|
// the command queues against a live agent, so offering it for a machine that
|
|
// is not checking in only produces a command nothing will collect.
|
|
if (canRunScript.value && liveness.value) {
|
|
tabs.push({ key: 'runscripts', label: 'Run Scripts' })
|
|
}
|
|
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('')
|
|
|
|
// Run Scripts state. The library is fetched rather than hardcoded (the opposite
|
|
// of corporateTools above) because these are site-authored scripts that change
|
|
// without an agent release.
|
|
const scriptlibrary = ref([])
|
|
const scriptsloading = ref(false)
|
|
const scriptsloaded = ref(false)
|
|
const selectedscriptid = ref('')
|
|
const firingScript = ref(false)
|
|
|
|
// Only scripts that can actually run. A row with no uploaded body is rejected
|
|
// at enqueue by _resolve_run_script, so listing it would offer a button whose
|
|
// only outcome is an error; the library page is where that gets fixed.
|
|
const runnablescripts = computed(
|
|
() => scriptlibrary.value.filter((script) => script.hasbody))
|
|
|
|
const selectedscript = computed(
|
|
() => runnablescripts.value.find(
|
|
(script) => String(script.scriptid) === String(selectedscriptid.value)) || null)
|
|
|
|
// Lazy and permission-gated: an operator without endpoints.runscript never has
|
|
// the tab, and so never issues a request the server would refuse anyway.
|
|
async function loadScriptLibrary() {
|
|
if (scriptsloaded.value || scriptsloading.value) return
|
|
scriptsloading.value = true
|
|
try {
|
|
const response = await scriptsApi.list({ active: 'true' })
|
|
scriptlibrary.value = response.data.data || []
|
|
scriptsloaded.value = true
|
|
} catch (error) {
|
|
toast.error(apiError(error, 'Failed to load the script library'))
|
|
} finally {
|
|
scriptsloading.value = false
|
|
}
|
|
}
|
|
|
|
// Fetch on first view of the tab rather than on page load: most visits to a PC
|
|
// never open this, and the library is irrelevant to every other tab.
|
|
watch(activegroup, (group) => {
|
|
if (group === 'runscripts' && canRunScript.value) loadScriptLibrary()
|
|
})
|
|
|
|
// 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 = ''
|
|
}
|
|
}
|
|
|
|
// Elevated: queues a run_script. commands.py asks for this to be gated "the way
|
|
// system_restart is gated", so the confirmation is TYPED rather than an OK
|
|
// button - and unlike Corporate Tools there is no non-destructive branch,
|
|
// because every script here is arbitrary code by definition. The confirm names
|
|
// the context and the folder so the operator sees what the library decided, not
|
|
// just the script's name.
|
|
async function confirmRunScript() {
|
|
const script = selectedscript.value
|
|
if (!script) return
|
|
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
|
const context = script.runcontext === 'user'
|
|
? 'the logged-on user'
|
|
: 'SYSTEM'
|
|
const folder = script.workingdirectory || 'the agent staging folder'
|
|
const typed = prompt(`Run "${script.scriptname}" on ${label}\n\n`
|
|
+ `It runs as ${context}, in ${folder}.\n`
|
|
+ 'It starts immediately and cannot be undone from here.'
|
|
+ '\n\nType RUN to confirm.')
|
|
if (typed !== 'RUN') return
|
|
|
|
firingScript.value = true
|
|
try {
|
|
await endpointsApi.enqueueCommand(
|
|
computer.value.assetid, 'run_script', `script:${script.scriptid}`)
|
|
// "Queued", not "finished": the agent collects this on its next check-in,
|
|
// and the script's own result comes back separately. Saying "ran" here
|
|
// would claim an outcome nothing has observed yet.
|
|
toast.success(`${script.scriptname} queued for ${label}`)
|
|
} catch (error) {
|
|
toast.error(apiError(error, `Failed to queue ${script.scriptname}`))
|
|
} finally {
|
|
firingScript.value = false
|
|
}
|
|
}
|
|
|
|
// Retire, not delete: the server deactivates the asset, so the PC
|
|
// leaves every list and report while its history and relationships stay put.
|
|
// Reversible by an admin setting isactive back to true.
|
|
const isRetired = computed(() => computer.value?.isactive === false)
|
|
|
|
async function restore() {
|
|
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
|
try {
|
|
await computersApi.update(route.params.id, { isactive: true })
|
|
toast.success(`Restored ${label}`)
|
|
const response = await computersApi.get(route.params.id)
|
|
computer.value = response.data.data
|
|
} catch (error) {
|
|
console.error('Error restoring:', error)
|
|
toast.error(apiError(error, 'Failed to restore'))
|
|
}
|
|
}
|
|
|
|
async function confirmRetire() {
|
|
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
|
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
|
+ `History and relationships are kept, and it can be restored.`)) return
|
|
try {
|
|
await computersApi.delete(route.params.id)
|
|
toast.success(`Retired ${label}`)
|
|
router.push(returnTo.value)
|
|
} catch (error) {
|
|
console.error('Error retiring PC:', error)
|
|
toast.error(apiError(error, 'Failed to retire PC'))
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.retired-banner {
|
|
border-left: 3px solid var(--warning);
|
|
margin-bottom: 1rem;
|
|
}
|
|
.portal-link {
|
|
font-size: 0.78rem;
|
|
margin-left: 0.5rem;
|
|
padding: 0.05rem 0.4rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: 3px;
|
|
color: var(--link, var(--primary));
|
|
text-decoration: none;
|
|
white-space: nowrap;
|
|
}
|
|
.portal-link:hover { border-color: var(--primary); }
|
|
|
|
/* PC-only header additions. These class names exist nowhere else in the app -
|
|
Machines, Printers and the rest share only .hero-details/.hero-detail-*
|
|
from global style.css, which is deliberately left alone. */
|
|
.hero-primary {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
/* Identity on the left, liveness and endpoint actions pinned to the right.
|
|
.hero-primary is a COLUMN (identity, then the portals row beneath), so the
|
|
corner block needs its own row here rather than margin-left:auto against
|
|
the column, which would just stack it under the identity. */
|
|
.hero-headline {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 1rem;
|
|
}
|
|
.hero-headline > .hero-identity { flex: 1 1 auto; }
|
|
@media (max-width: 768px) {
|
|
.hero-headline { flex-wrap: wrap; }
|
|
}
|
|
.hero-identity {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
min-width: 0;
|
|
}
|
|
/* Horizontal, in the same visual language as .hero-details directly above it,
|
|
separated by a rule rather than sitting in a box. Wraps to a second line on
|
|
a narrow window instead of forcing a scrollbar. */
|
|
.hero-portals {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 2rem;
|
|
padding-top: 0.85rem;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
.hero-portal {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
min-width: 0;
|
|
}
|
|
/* The portal button lives on the label line, so it reads as an action on the
|
|
id below it and adds no height of its own. */
|
|
.hero-portal-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 10px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
color: var(--text-light);
|
|
}
|
|
.hero-portal-value {
|
|
font-size: 15px;
|
|
font-weight: 500;
|
|
color: var(--text);
|
|
}
|
|
/* Full id, never truncated: a half-copied GUID looks complete and is worse
|
|
than none. user-select:all keeps manual selection working as a fallback for
|
|
when the clipboard API is unavailable over plain HTTP. */
|
|
.hero-portal-idrow {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
min-width: 0;
|
|
}
|
|
.hero-portal-id {
|
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
|
font-size: 13px;
|
|
color: var(--text);
|
|
user-select: all;
|
|
white-space: nowrap;
|
|
}
|
|
.copy-btn {
|
|
flex: 0 0 auto;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0.2rem;
|
|
line-height: 0;
|
|
color: var(--text-light);
|
|
background: none;
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.25rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Liveness + endpoint actions in the header corner. Pinned to the top right of
|
|
.hero-primary, which is the flex row holding the identity block. */
|
|
.hero-endpoint {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
gap: 0.75rem;
|
|
margin-left: auto;
|
|
flex: 0 0 auto;
|
|
padding-left: 1.5rem;
|
|
}
|
|
.hero-status {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
gap: 0.45rem;
|
|
}
|
|
/* A touch larger than the shared hero label (10px in style.css). Scoped to
|
|
this one rather than bumping .hero-detail-label, which PC Type / OS /
|
|
Location also use and which is sized to sit quietly under the identity. */
|
|
.hero-status-label {
|
|
font-size: 12px;
|
|
}
|
|
.hero-endpoint-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-end;
|
|
gap: 0.5rem;
|
|
}
|
|
/* 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;
|
|
}
|
|
/* Run Scripts reuses the Corporate Tools intro/warning styles above; only the
|
|
picker-specific pieces are new. */
|
|
.runscripts-empty {
|
|
padding: 0.75rem 0;
|
|
color: var(--text-secondary);
|
|
font-size: 0.875rem;
|
|
}
|
|
.runscripts-detail {
|
|
margin-top: 0.75rem;
|
|
padding: 0.75rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
}
|
|
.runscripts-desc {
|
|
margin: 0 0 0.5rem;
|
|
color: var(--text-secondary);
|
|
font-size: 0.8125rem;
|
|
}
|
|
/* Two columns so the label and its value stay on one line: the folder is the
|
|
fact most likely to be skimmed, and wrapping it under its own label made it
|
|
read as body copy in review. */
|
|
.runscripts-facts {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr;
|
|
gap: 0.25rem 0.75rem;
|
|
margin: 0;
|
|
font-size: 0.8125rem;
|
|
}
|
|
.runscripts-facts dt {
|
|
color: var(--text-secondary);
|
|
}
|
|
.runscripts-facts dd {
|
|
margin: 0;
|
|
color: var(--text-primary);
|
|
}
|
|
/* Defined locally: .mono is not a global utility in this build, and a path
|
|
rendered in the body font is harder to read back against the endpoint. */
|
|
.runscripts-facts dd.mono {
|
|
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
word-break: break-all;
|
|
}
|
|
.runscripts-note {
|
|
margin: 0.5rem 0 0;
|
|
color: var(--text-secondary);
|
|
font-size: 0.8125rem;
|
|
}
|
|
.runscripts-actions {
|
|
margin-top: 1rem;
|
|
}
|
|
.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>
|