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;
|
||||
|
||||
@@ -10,8 +10,8 @@ schema changes, update this file in the same change.
|
||||
- Method/URL: `POST {baseurl}/api/collector/endpoints`
|
||||
- Auth header: `X-API-Key: <collector key>` (Bearer token also accepted). The
|
||||
key is a UI-managed, endpoints-only service token minted from Settings ->
|
||||
Endpoint agent key (section 6). A bootstrap `.env` collector key still works
|
||||
until the first UI key is generated, then it is switched off (section 6). No
|
||||
Endpoint agent key (section 7). A bootstrap `.env` collector key still works
|
||||
until the first UI key is generated, then it is switched off (section 7). No
|
||||
querystring fallback.
|
||||
- Body: `application/json`, a single flat object (NOT an array). One POST per
|
||||
report per endpoint.
|
||||
@@ -194,10 +194,11 @@ Notes:
|
||||
rather than a string match against a Windows enum the server would have to
|
||||
keep in sync. `null` means the drive did not answer: unknown, NOT healthy.
|
||||
`healthstatus` keeps the human-readable source value alongside it.
|
||||
- `usercontext` is gathered by a probe the agent runs inside the signed-in
|
||||
user's session, because mapped drives, user-owned listeners and idle time do
|
||||
not exist in the agent's own LocalSystem session 0. It is omitted when no one
|
||||
is signed in, which is a normal unattended state, not a fault.
|
||||
- `usercontext` is gathered by a probe the TRAY runs inside the signed-in
|
||||
operator's session, because mapped drives, user-owned listeners and idle time
|
||||
do not exist in the agent's own LocalSystem session 0. It is omitted when no
|
||||
one is signed in, which is a normal unattended state, not a fault. The
|
||||
security-context split that makes this work is normative; see section 5.
|
||||
- `ports` carries LISTENING sockets only. The full connection table is available
|
||||
on demand via the `network_connections` command instead, because established
|
||||
connections churn constantly and would change every report for no diagnostic
|
||||
@@ -294,7 +295,49 @@ public sealed class CollectorResponse
|
||||
Serializer settings: `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull`
|
||||
so omitted fields are not sent (server upserts only present keys). Send UTC ISO-8601.
|
||||
|
||||
## 5. "Build site agent" settings flow (build cadence)
|
||||
## 5. Security contexts, spool handoff and log destinations (NORMATIVE)
|
||||
|
||||
Three processes run as three different principals. Confusing them is what makes
|
||||
`usercontext` silently empty, so the boundaries are fixed by contract.
|
||||
|
||||
| Process | Runs as | May write to |
|
||||
|---|---|---|
|
||||
| Service (`shopdbagent.exe`) | LocalSystem, session 0 | `%ProgramData%\ShopDB\EndpointAgent\logs\agent-<date>.log` |
|
||||
| Tray + probe (`shopdb-endpoint-tray.exe`, `shopdbagent.exe --collect-usercontext`) | `ShopFloor` operator, interactive session | `C:\Users\ShopFloor\ShopDB\` and `C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log` |
|
||||
| Installer (`install-shopdbagent.ps1`) | Elevated admin, one-shot | `%ProgramData%\ShopDB\Logs\<datetime>-shopdbinstalls.log` |
|
||||
|
||||
Rules:
|
||||
|
||||
1. **The operator cannot write to `C:\Program Files` or to the SYSTEM-owned
|
||||
`%ProgramData%\ShopDB\EndpointAgent\logs`.** Anything running in the user's
|
||||
session MUST log under `C:\Users\ShopFloor\ShopDB\Logs`. The file log sink
|
||||
degrades to a silent no-op when it cannot create its directory, so violating
|
||||
this does not raise an error - it discards the log line and the failure
|
||||
becomes invisible.
|
||||
2. **Only the tray collects user context**, because mapped drives, user-owned
|
||||
listeners and idle time are per-session and do not exist in session 0. The
|
||||
service MUST NOT try to descend into the session to collect them.
|
||||
3. **The tray WRITES the spool; the service READS it and then REMOVES it.**
|
||||
The service consumes the file after a successful parse, so a tray that has
|
||||
stopped running cannot keep re-reporting stale mapped drives as current. A
|
||||
file that fails to parse (a read that raced the write) is left in place.
|
||||
4. **The spool location is fixed at `C:\Users\ShopFloor\ShopDB\usercontext.json`.**
|
||||
Every shop-floor endpoint uses the same local `ShopFloor` account. Resolving
|
||||
it through `WTSQueryUserToken` is a fallback only: that call is privileged,
|
||||
endpoint protection blocks it, and when it fails the writer and reader
|
||||
disagree about the location with no error on either side.
|
||||
5. **Losing user context is never fatal.** If the `ShopFloor` profile is absent
|
||||
or unwritable, `usercontext` is omitted and telemetry, commands, inventory
|
||||
and the command channel continue unaffected.
|
||||
6. **The installer provisions** `C:\Users\ShopFloor\ShopDB\` and its `Logs`
|
||||
subdirectory with `ShopFloor: Modify` before the tray first runs.
|
||||
|
||||
The three log destinations are deliberately separate: an install is one-shot and
|
||||
elevated, the service log is a rolling runtime log, and the tray log is the only
|
||||
one written from the operator's session. Merging them makes an install
|
||||
indistinguishable from runtime behaviour when reading a failure back.
|
||||
|
||||
## 6. "Build site agent" settings flow (build cadence)
|
||||
|
||||
Goal: an operator in ShopDB Settings produces an instance-locked `.exe` that
|
||||
knows its server URL and its collector key, needing no manual config on the
|
||||
@@ -321,7 +364,7 @@ Open item (from DESIGN-DECISIONS-agent.md 1a): whether `adopt` stays a
|
||||
per-report flag or is promoted to a site setting (strict vs self-adopting
|
||||
fleet). Default proposed: agent sends `adopt=true` on first run only.
|
||||
|
||||
## 6. UI-managed agent key (kill switch)
|
||||
## 7. UI-managed agent key (kill switch)
|
||||
|
||||
The endpoint agent authenticates with a single UI-managed key scoped to ONLY
|
||||
`collector.ingest.endpoints`, so rotating or revoking it stops all
|
||||
@@ -346,13 +389,13 @@ The bootstrap `.env` key (`COLLECTOR_API_KEY[_ENDPOINTS]`) remains only as a
|
||||
first-run path before a UI key exists; it cannot be the runtime kill switch
|
||||
because container env is injected at boot and cannot be rotated live.
|
||||
|
||||
## 7. Command channel (WebUI -> agent)
|
||||
## 8. Command channel (WebUI -> agent)
|
||||
|
||||
The WebUI can send an endpoint an ALLOWLISTED command (never an arbitrary
|
||||
command string). Delivery is agent-initiated long-poll, so shop-floor PCs open
|
||||
no inbound port: the agent makes an outbound request the server holds open until
|
||||
a command is queued, runs it locally, and posts the result back. All three
|
||||
routes reuse the same endpoints collector key (section 6) via `X-API-Key`, so
|
||||
routes reuse the same endpoints collector key (section 7) via `X-API-Key`, so
|
||||
the command channel shares the kill switch and adds no second secret.
|
||||
|
||||
Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
|
||||
|
||||
@@ -26,10 +26,18 @@ from shopdb.api import (success_response, error_response, ErrorCodes, AuditLog,
|
||||
from .routes import endpoints_bp
|
||||
from . import agentstore
|
||||
|
||||
# Hard cap on an uploaded agent binary. The real agent is a few MB; this is a
|
||||
# generous ceiling that still refuses an operator who points at a giant wrong
|
||||
# file before it is buffered fully into memory-staged storage.
|
||||
_MAX_UPLOAD_BYTES = 64 * 1024 * 1024
|
||||
# Hard cap on an uploaded agent binary. This is a generous ceiling that still
|
||||
# refuses an operator who points at a giant wrong file before it is buffered
|
||||
# fully into memory-staged storage.
|
||||
#
|
||||
# 128 MB, not 64: the self-contained single-file tray publishes at ~69 MB
|
||||
# (it carries the whole .NET runtime plus WinForms), which the old 64 MB cap
|
||||
# rejected outright. The upload failed on SIZE, but the operator saw only a
|
||||
# version-detection failure, because the size check returns before the PE is
|
||||
# ever parsed. Config.MAX_CONTENT_LENGTH must stay above this - it bounds the
|
||||
# whole multipart envelope, so a cap equal to it would reject a legal file on
|
||||
# the boundary bytes alone.
|
||||
_MAX_UPLOAD_BYTES = 128 * 1024 * 1024
|
||||
|
||||
|
||||
@endpoints_bp.route('/siteagent/binaries/<binkey>', methods=['GET'])
|
||||
|
||||
@@ -1413,6 +1413,137 @@ def panel_interfaces(asset_id: int):
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
# Columns for the two cards carved out of the old hand-written Network card on
|
||||
# the Details tab. That card listed current addresses with a Primary badge and
|
||||
# nothing else, which undersold what the rows actually hold.
|
||||
_IPHISTORY_COLUMNS = [
|
||||
{'key': 'ipaddress', 'label': 'IP address'},
|
||||
{'key': 'macaddress', 'label': 'MAC'},
|
||||
{'key': 'status', 'label': 'Status'},
|
||||
{'key': 'firstseen', 'label': 'First seen'},
|
||||
{'key': 'lastchanged', 'label': 'Last changed'},
|
||||
]
|
||||
|
||||
_IPCONFIG_COLUMNS = [
|
||||
{'key': 'ipaddress', 'label': 'IP address'},
|
||||
{'key': 'subnetmask', 'label': 'Subnet mask'},
|
||||
{'key': 'gateway', 'label': 'Gateway'},
|
||||
{'key': 'dns1', 'label': 'DNS 1'},
|
||||
{'key': 'dns2', 'label': 'DNS 2'},
|
||||
{'key': 'assignment', 'label': 'Assignment'},
|
||||
]
|
||||
|
||||
|
||||
def _isostamp(value):
|
||||
"""ISO-8601 with a Z suffix, matching every other timestamp this file emits.
|
||||
|
||||
Formatting is left to the client so it can render in the viewer's locale
|
||||
and timezone; returning a preformatted string here would pin every reader
|
||||
to the server's.
|
||||
"""
|
||||
return value.isoformat() + 'Z' if value else None
|
||||
|
||||
|
||||
def _communication_rows(asset_id: int):
|
||||
"""Every Communication row for an asset, newest change first.
|
||||
|
||||
These rows are an ACCIDENTAL history rather than a designed one.
|
||||
_record_interfaces in the computers plugin matches an incoming NIC on MAC
|
||||
then on IP and inserts when neither matches, and it never deletes - so an
|
||||
address the machine has not held for a year is still sitting here next to
|
||||
the current one. That is genuinely useful (it answers "what was this box on
|
||||
before?"), so it is surfaced as history rather than cleaned away.
|
||||
"""
|
||||
from shopdb.api import Communication
|
||||
|
||||
# MySQL has no NULLS LAST, so sort on an explicit "is null" key first.
|
||||
# Rows that never got a modifieddate sink to the bottom on every dialect.
|
||||
return (Communication.query
|
||||
.filter_by(assetid=asset_id)
|
||||
.order_by(Communication.modifieddate.is_(None),
|
||||
Communication.modifieddate.desc(),
|
||||
Communication.communicationid.desc())
|
||||
.all())
|
||||
|
||||
|
||||
def _iphistory_status(comm):
|
||||
"""What this row IS, in the vocabulary somebody troubleshooting would use.
|
||||
|
||||
Primary is the address the collector last picked as this box's main one.
|
||||
APIPA is called out by name because a 169.254.x.x address is not an address
|
||||
the machine was given - it is what Windows assigns when DHCP failed, so its
|
||||
presence in the list is a record of a past network fault, not a past
|
||||
subnet.
|
||||
"""
|
||||
address = (comm.ipaddress or '').strip()
|
||||
if address.startswith('169.254.'):
|
||||
return 'APIPA (no DHCP)'
|
||||
if comm.isprimary:
|
||||
return 'Primary'
|
||||
if comm.ismachinenetwork:
|
||||
return 'Machine network'
|
||||
return 'Previous'
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/iphistory', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_iphistory(asset_id: int):
|
||||
"""Table panel: every address this asset has held, newest change first.
|
||||
|
||||
Replaces the hand-written Network card that used to sit on the Details tab
|
||||
and show only the current addresses. Dates come from the row's own
|
||||
lifecycle columns, so they are labelled for what they are: 'Last changed'
|
||||
is when a field on the row last moved, NOT when the address was last seen.
|
||||
An address the machine still holds and reports unchanged does not update
|
||||
modifieddate, so calling that column 'Last seen' would be a lie the data
|
||||
cannot support.
|
||||
"""
|
||||
rows = []
|
||||
for comm in _communication_rows(asset_id):
|
||||
address = (comm.ipaddress or '').strip()
|
||||
if not address:
|
||||
continue
|
||||
rows.append({
|
||||
'ipaddress': address,
|
||||
'macaddress': (comm.macaddress or '').strip() or '-',
|
||||
'status': _iphistory_status(comm),
|
||||
'firstseen': _isostamp(comm.createddate),
|
||||
'lastchanged': _isostamp(comm.modifieddate),
|
||||
})
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/ipconfig', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_ipconfig(asset_id: int):
|
||||
"""Table panel: subnet, gateway and DNS per recorded address.
|
||||
|
||||
Split from IP history rather than folded into it: history answers "what has
|
||||
this box been", configuration answers "how is this address set up", and one
|
||||
table trying to do both would be eleven columns wide. Rows with no
|
||||
configuration recorded are dropped - an address with no subnet, gateway or
|
||||
DNS contributes nothing here and already appears in the history card.
|
||||
"""
|
||||
rows = []
|
||||
for comm in _communication_rows(asset_id):
|
||||
address = (comm.ipaddress or '').strip()
|
||||
subnet = (comm.subnetmask or '').strip()
|
||||
gateway = (comm.gateway or '').strip()
|
||||
dns1 = (comm.dns1 or '').strip()
|
||||
dns2 = (comm.dns2 or '').strip()
|
||||
if not address or not (subnet or gateway or dns1 or dns2):
|
||||
continue
|
||||
rows.append({
|
||||
'ipaddress': address,
|
||||
'subnetmask': subnet or '-',
|
||||
'gateway': gateway or '-',
|
||||
'dns1': dns1 or '-',
|
||||
'dns2': dns2 or '-',
|
||||
'assignment': 'DHCP' if comm.isdhcp else 'Static',
|
||||
})
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/applications', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_applications(asset_id: int):
|
||||
|
||||
@@ -319,9 +319,9 @@
|
||||
<div
|
||||
v-for="binary in binaries"
|
||||
:key="binary.key"
|
||||
class="binary-block"
|
||||
class="card binary-block"
|
||||
>
|
||||
<h4>{{ binary.label }}</h4>
|
||||
<h4 class="binary-title">{{ binary.label }}</h4>
|
||||
<p class="field-hint">
|
||||
Upload {{ binary.name }} updated EXE. Uploading here will update the
|
||||
binaries available for your "Build Site Agent" install package.
|
||||
@@ -910,10 +910,18 @@ function editConfiguration() {
|
||||
.settings-section > h3 { margin-bottom: 0.25rem; }
|
||||
.info-list { margin: 0.25rem 0 0; padding-left: 1.25rem; }
|
||||
.info-list li { margin-bottom: 0.5rem; }
|
||||
/* Each agent is its own nested card inside the collapsible, so the two
|
||||
binaries read as distinct units instead of one continuous wall of
|
||||
controls separated by a hairline rule. */
|
||||
.binary-block {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border, #333);
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.binary-block:first-of-type { margin-top: 0.75rem; }
|
||||
.binary-title {
|
||||
margin: 0 0 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border, #333);
|
||||
}
|
||||
.binary-upload {
|
||||
display: flex;
|
||||
|
||||
@@ -507,7 +507,66 @@ class EndpointsPlugin(BasePlugin):
|
||||
],
|
||||
'empty': 'No network interfaces reported.',
|
||||
'position': 16,
|
||||
# adapter state is watched, not something you act on
|
||||
# adapter state is watched, not something you act on.
|
||||
# Declared in monitors but RENDERED at the top of the Details
|
||||
# tab (PCDetail.vue lifts it by id and excludes it here): which
|
||||
# adapter is up and what address it holds is asked at the same
|
||||
# moment as "what is this box".
|
||||
'group': 'monitors',
|
||||
},
|
||||
{
|
||||
# What this box has been on the network, carved out of the
|
||||
# hand-written Network card that used to sit on Details and
|
||||
# show only current addresses with a Primary badge.
|
||||
#
|
||||
# The history is ACCIDENTAL: _record_interfaces matches on MAC
|
||||
# then IP and never deletes, so old addresses accumulate. That
|
||||
# turned out to be worth reading rather than worth cleaning
|
||||
# away, so it is presented as what it already is.
|
||||
'id': 'endpoints-iphistory',
|
||||
'title': 'IP History',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/iphistory',
|
||||
'render': 'table',
|
||||
'collapsible': True,
|
||||
'columns': [
|
||||
{'key': 'ipaddress', 'label': 'IP address', 'mono': True},
|
||||
{'key': 'macaddress', 'label': 'MAC', 'mono': True},
|
||||
{'key': 'status', 'label': 'Status'},
|
||||
{'key': 'firstseen', 'label': 'First seen',
|
||||
'format': 'date'},
|
||||
# NOT "last seen": modifieddate only moves when a field on
|
||||
# the row changes, so an address still held and reported
|
||||
# unchanged keeps an old stamp here.
|
||||
{'key': 'lastchanged', 'label': 'Last changed',
|
||||
'format': 'date'},
|
||||
],
|
||||
'empty': 'No addresses on record for this endpoint.',
|
||||
'position': 16.5,
|
||||
'group': 'monitors',
|
||||
},
|
||||
{
|
||||
# The other half of the old Network card: how each address is
|
||||
# configured. Split from history because one table carrying
|
||||
# both would be eleven columns wide, and the two answer
|
||||
# different questions - what has this box been, versus how is
|
||||
# this address set up.
|
||||
'id': 'endpoints-ipconfig',
|
||||
'title': 'IP Configuration',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/ipconfig',
|
||||
'render': 'table',
|
||||
'collapsible': True,
|
||||
'columns': [
|
||||
{'key': 'ipaddress', 'label': 'IP address', 'mono': True},
|
||||
{'key': 'subnetmask', 'label': 'Subnet mask', 'mono': True},
|
||||
{'key': 'gateway', 'label': 'Gateway', 'mono': True},
|
||||
{'key': 'dns1', 'label': 'DNS 1', 'mono': True},
|
||||
{'key': 'dns2', 'label': 'DNS 2', 'mono': True},
|
||||
{'key': 'assignment', 'label': 'Assignment'},
|
||||
],
|
||||
'empty': 'No addressing configuration on record.',
|
||||
'position': 16.6,
|
||||
'group': 'monitors',
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user