Fix user-context collection, split agent log destinations, widen upload cap
Mapped drives stopped reporting because the user-context path failed silently in three separate places, and none of them left a trace to read back. - Probe: launch via -EncodedCommand instead of staging a temp .ps1 and running powershell.exe -File. A missing or quarantined script file fails BEFORE any statement runs, so the script's own try/catch never sees it and the only symptom is exit -196608. - Spool: pin to the ShopFloor profile. Resolving it through WTSQueryUserToken is privileged, endpoint protection blocks it, and on failure the tray (writer) and service (reader) silently disagree about the path. - Spool: consume after a successful parse, so a stopped tray cannot keep re-reporting stale mapped drives as current. Unparseable files are left in place for the next cycle. - Logging: user-session components cannot write to Program Files or to the SYSTEM-owned ProgramData log directory, and the file sink degrades to a silent no-op when it cannot create its directory. Tray and probe now log to C:\Users\ShopFloor\ShopDB\Logs, installs transcript to ProgramData, and the sink records a SinkProblem instead of swallowing UnauthorizedAccessException. - Installer: provision the operator spool/log directories with ShopFloor:Modify before the tray first runs, and stop the transcript via trap so a throw cannot orphan exactly the failed install worth reading. - Upload cap 64 -> 128 MB: the self-contained tray publishes at ~69 MB and was rejected on SIZE, but the size check returns before the PE is parsed, so the operator only ever saw a version-detection failure. AGENT-CONTRACT.md gains a normative section defining the three security contexts, their log destinations, the fixed spool path and consume-on-read. Agent 1.8.0 -> 1.8.1, tray 1.1.0 -> 1.1.1 so the deployed build is identifiable from a log line.
This commit is contained in:
@@ -34,32 +34,103 @@
|
||||
<img :src="withBase(computer.computer.imageurl)" :alt="computer.computer.modelname || 'Model photo'" />
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ computer.assetnumber }}</h1>
|
||||
<span v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span class="badge badge-lg badge-info">Computer</span>
|
||||
<span class="badge badge-lg" :style="colorStyle(computer.statuscolor)">
|
||||
{{ computer.statusname || 'Unknown' }}
|
||||
</span>
|
||||
<span v-if="heroWarranty" class="badge badge-lg" :style="colorStyle(heroWarranty.statuscolor)"
|
||||
:title="heroWarranty.enddate ? `Warranty ends ${formatWarrantyDate(heroWarranty.enddate)}` : 'Warranty'">
|
||||
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ formatWarrantyDate(heroWarranty.enddate) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="hero-detail" v-if="computer.computer?.computertypename">
|
||||
<span class="hero-detail-label">PC Type</span>
|
||||
<span class="hero-detail-value">{{ computer.computer.computertypename }}</span>
|
||||
<!-- Identity, then the cloud row beneath it. An earlier cut put the
|
||||
portal block in a fixed-width rail on the right; GUIDs are too
|
||||
long for a narrow column and it wrapped into a tall ragged
|
||||
stack. Full width in the normal flow costs one short row. -->
|
||||
<div class="hero-primary">
|
||||
<div class="hero-identity">
|
||||
<div class="hero-title">
|
||||
<h1>{{ computer.assetnumber }}</h1>
|
||||
<span v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span class="badge badge-lg badge-info">Computer</span>
|
||||
<span class="badge badge-lg" :style="colorStyle(computer.statuscolor)">
|
||||
{{ computer.statusname || 'Unknown' }}
|
||||
</span>
|
||||
<span v-if="heroWarranty" class="badge badge-lg" :style="colorStyle(heroWarranty.statuscolor)"
|
||||
:title="heroWarranty.enddate ? `Warranty ends ${formatWarrantyDate(heroWarranty.enddate)}` : 'Warranty'">
|
||||
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ formatWarrantyDate(heroWarranty.enddate) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="hero-detail" v-if="computer.computer?.computertypename">
|
||||
<span class="hero-detail-label">PC Type</span>
|
||||
<span class="hero-detail-value">{{ computer.computer.computertypename }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="computer.computer?.osname">
|
||||
<span class="hero-detail-label">OS</span>
|
||||
<span class="hero-detail-value">{{ computer.computer.osname }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="computer.locationname">
|
||||
<span class="hero-detail-label">Location</span>
|
||||
<span class="hero-detail-value">{{ computer.locationname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Cloud identity. Laid out as a horizontal row in the same
|
||||
label-over-value language as hero-details, not as a narrow
|
||||
right rail: a 36-character GUID cannot wrap sanely into a
|
||||
300px column, and the ragged stack that produced grew taller
|
||||
than the identity block beside it.
|
||||
Held back until the portal templates resolve so the row does
|
||||
not paint once without its buttons and then again with them. -->
|
||||
<div v-if="hasCloudIdentity" class="hero-portals">
|
||||
<!-- The link sits beside the id it is built from, not in a row
|
||||
of its own: the pairing is counterintuitive - the DEVICE id
|
||||
opens Intune and the OBJECT id opens Entra - and a separate
|
||||
action row would have to re-label which is which. The full
|
||||
id stays selectable because it gets copied into tickets. -->
|
||||
<div class="hero-portal">
|
||||
<span class="hero-portal-label">
|
||||
Device ID
|
||||
<a
|
||||
v-if="intuneDeviceLink"
|
||||
:href="intuneDeviceLink"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="portal-link"
|
||||
>Intune</a>
|
||||
</span>
|
||||
<span v-if="computer.computer?.azuredeviceid" class="hero-portal-idrow">
|
||||
<span class="hero-portal-id mono">{{ computer.computer.azuredeviceid }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="copy-btn"
|
||||
title="Copy Device ID"
|
||||
@click="copyId(computer.computer.azuredeviceid, 'Device ID')"
|
||||
>Copy</button>
|
||||
</span>
|
||||
<span v-else class="hero-portal-missing">Please provide DeviceID</span>
|
||||
</div>
|
||||
<div class="hero-portal">
|
||||
<span class="hero-portal-label">
|
||||
Object ID
|
||||
<a
|
||||
v-if="entraDeviceLink"
|
||||
:href="entraDeviceLink"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="portal-link"
|
||||
>Entra</a>
|
||||
</span>
|
||||
<span v-if="computer.computer?.azureobjectid" class="hero-portal-idrow">
|
||||
<span class="hero-portal-id mono">{{ computer.computer.azureobjectid }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="copy-btn"
|
||||
title="Copy Object ID"
|
||||
@click="copyId(computer.computer.azureobjectid, 'Object ID')"
|
||||
>Copy</button>
|
||||
</span>
|
||||
<span v-else class="hero-portal-missing">Please provide ObjectID</span>
|
||||
</div>
|
||||
<div class="hero-portal" v-if="computer.computer?.azuredevicecategory">
|
||||
<span class="hero-portal-label">Category</span>
|
||||
<span class="hero-portal-value">{{ computer.computer.azuredevicecategory }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,8 +167,35 @@
|
||||
answering for this asset type. -->
|
||||
<AssetDetailTabs v-model="activegroup" :tabs="detailtabs" label="Computer detail sections" />
|
||||
|
||||
<!-- Network interfaces is pinned to the top of Details rather than left
|
||||
in System Monitors: which adapter is up and what address it holds is
|
||||
identity, asked at the same moment as "what is this box". It still
|
||||
BELONGS to monitors (declared there, and excluded from that tab
|
||||
below), so there is one declaration and no new group. Full width and
|
||||
outside content-grid because the table has eight columns and would
|
||||
be unreadable in a half-width column. -->
|
||||
<div v-show="activegroup === 'details'" class="detail-full">
|
||||
<PluginAssetPanels
|
||||
:assetid="computer.assetid"
|
||||
:only="['endpoints-interfaces']"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-show="activegroup === 'details'" class="content-grid">
|
||||
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" group="details" />
|
||||
<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">
|
||||
@@ -199,70 +297,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entra / Azure device info -->
|
||||
<div
|
||||
v-if="computer.computer?.azuredeviceid || computer.computer?.azureobjectid || computer.computer?.azuredevicecategory"
|
||||
class="section-card"
|
||||
>
|
||||
<h3 class="section-title">Entra Device Info</h3>
|
||||
<div class="info-list">
|
||||
<!-- The link sits beside the id it is built from, not in a row
|
||||
of its own at the top: the pairing is counterintuitive - the
|
||||
DEVICE id opens Intune and the OBJECT id opens Entra - and a
|
||||
separate action row would have to re-label which is which. -->
|
||||
<div v-if="computer.computer?.azuredeviceid" class="info-row">
|
||||
<span class="info-label">Device ID</span>
|
||||
<span class="info-value">
|
||||
{{ computer.computer.azuredeviceid }}
|
||||
<a
|
||||
v-if="intuneDeviceLink"
|
||||
:href="intuneDeviceLink"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="portal-link"
|
||||
>Intune</a>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="computer.computer?.azureobjectid" class="info-row">
|
||||
<span class="info-label">Object ID</span>
|
||||
<span class="info-value">
|
||||
{{ computer.computer.azureobjectid }}
|
||||
<a
|
||||
v-if="entraDeviceLink"
|
||||
:href="entraDeviceLink"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="portal-link"
|
||||
>Entra</a>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="computer.computer?.azuredevicecategory" class="info-row">
|
||||
<span class="info-label">Device Category</span>
|
||||
<span class="info-value">{{ computer.computer.azuredevicecategory }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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">
|
||||
<!-- Network -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Network</h3>
|
||||
<div v-if="computer.communications?.length" class="network-list">
|
||||
<div v-for="comm in computer.communications" :key="comm.communicationid" class="network-item">
|
||||
<div class="network-primary">
|
||||
<span class="ip-address mono">{{ comm.ipaddress || comm.address || '-' }}</span>
|
||||
<span v-if="comm.communicationtypename" class="comm-type">{{ comm.communicationtypename }}</span>
|
||||
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
|
||||
</div>
|
||||
<div class="network-secondary" v-if="comm.macaddress">
|
||||
<span class="mac-address mono">{{ comm.macaddress }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="muted">No network addresses on record</p>
|
||||
</div>
|
||||
<!-- 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">
|
||||
@@ -344,7 +392,11 @@
|
||||
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" />
|
||||
<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" />
|
||||
@@ -433,6 +485,11 @@ const activegroup = ref('details')
|
||||
// blade paths, so guessing one would send people to a dead page.
|
||||
const entraUrlTemplate = ref('')
|
||||
const intuneUrlTemplate = ref('')
|
||||
// The templates arrive from an async settings fetch. Until that settles the
|
||||
// block stays hidden: rendering the ids first and popping the Intune/Entra
|
||||
// buttons in a moment later reads as the buttons being missing, which is
|
||||
// exactly what it looked like before a manual refresh warmed the cache.
|
||||
const portalTemplatesLoaded = ref(false)
|
||||
|
||||
function fillTemplate(template, token, value) {
|
||||
if (!template || !value) return null
|
||||
@@ -443,6 +500,30 @@ const entraDeviceLink = computed(() => fillTemplate(
|
||||
const intuneDeviceLink = computed(() => fillTemplate(
|
||||
intuneUrlTemplate.value, '{deviceid}', computer.value?.computer?.azuredeviceid))
|
||||
|
||||
// clipboard.writeText needs a secure context: it is undefined over plain HTTP
|
||||
// and rejects when the tab is not focused. Say copy failed and leave the id
|
||||
// selectable rather than reporting a success that did not happen.
|
||||
async function copyId(value, label) {
|
||||
if (!value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
toast.success(`${label} copied.`)
|
||||
} catch (err) {
|
||||
toast.error(`Copy failed - select the ${label} and copy manually.`)
|
||||
}
|
||||
}
|
||||
|
||||
// Whether to show the header's cloud block at all.
|
||||
//
|
||||
// Gated on the site having CONFIGURED a portal template, not on this PC having
|
||||
// an id. A site that does not use Intune/Entra has no templates, so the block
|
||||
// is absent entirely rather than permanently asking for an id it will never
|
||||
// get. Where templates exist, a PC missing an id shows which one is missing -
|
||||
// that is the actionable case the empty state was requested for.
|
||||
const hasCloudIdentity = computed(() => portalTemplatesLoaded.value && Boolean(
|
||||
entraUrlTemplate.value || intuneUrlTemplate.value
|
||||
|| computer.value?.computer?.azuredevicecategory))
|
||||
|
||||
// Only offer a tab that has something in it. An empty tab is worse than an
|
||||
// absent one - it reads as broken rather than as "this site does not collect
|
||||
// that". Details always shows because core fills it regardless of plugins.
|
||||
@@ -534,7 +615,11 @@ onMounted(async () => {
|
||||
getEntraDeviceUrls().then(({ entraUrl, intuneUrl }) => {
|
||||
entraUrlTemplate.value = entraUrl
|
||||
intuneUrlTemplate.value = intuneUrl
|
||||
}).catch(() => {})
|
||||
}).catch(() => {}).finally(() => {
|
||||
// Settled either way. On failure the templates stay empty and the block
|
||||
// falls back to whatever else it has, rather than hiding forever.
|
||||
portalTemplatesLoaded.value = true
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await computersApi.get(route.params.id)
|
||||
@@ -722,6 +807,96 @@ async function confirmRetire() {
|
||||
}
|
||||
.portal-link:hover { border-color: var(--primary); }
|
||||
|
||||
/* PC-only header additions. These class names exist nowhere else in the app -
|
||||
Machines, Printers and the rest share only .hero-details/.hero-detail-*
|
||||
from global style.css, which is deliberately left alone. */
|
||||
.hero-primary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.hero-identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Horizontal, in the same visual language as .hero-details directly above it,
|
||||
separated by a rule rather than sitting in a box. Wraps to a second line on
|
||||
a narrow window instead of forcing a scrollbar. */
|
||||
.hero-portals {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2rem;
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.hero-portal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
/* The portal button lives on the label line, so it reads as an action on the
|
||||
id below it and adds no height of its own. */
|
||||
.hero-portal-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.hero-portal-value {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
/* Full id, never truncated: a half-copied GUID looks complete and is worse
|
||||
than none. user-select:all keeps manual selection working as a fallback for
|
||||
when the clipboard API is unavailable over plain HTTP. */
|
||||
.hero-portal-idrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.hero-portal-id {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
user-select: all;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.copy-btn {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.1rem 0.4rem;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-light);
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.copy-btn:hover {
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.hero-portal-missing {
|
||||
font-size: 13px;
|
||||
color: var(--text-light);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero-portals { gap: 1rem 1.5rem; }
|
||||
/* Only here does the id need to break: at this width it cannot fit a line. */
|
||||
.hero-portal-id { white-space: normal; overflow-wrap: anywhere; }
|
||||
}
|
||||
|
||||
|
||||
/* PC-specific styles - shared styles are in global style.css */
|
||||
|
||||
@@ -810,43 +985,8 @@ async function confirmRetire() {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Network card (IP / MAC list) */
|
||||
.network-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.network-item {
|
||||
padding: 0.5rem 0.7rem;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.network-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ip-address {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.comm-type {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.primary-badge {
|
||||
padding: 0.1rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.mac-address {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
/* 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;
|
||||
|
||||
Reference in New Issue
Block a user