Ports 37 of Benjamin Baker's commits from the wilm branch, mirrored from the
corporate GitHub at 7905917c. wilm rides the publication lineage and shares no
ancestor with wj, so this is a filtered tree patch rather than a merge:
94 files, and every path the publication export strips (docs/, tools/, mcp/,
CLAUDE.md, start-*.sh, .gitea/, the three excluded tests) is held back, because
a raw diff would delete each of them from this tree.
What comes across:
* Endpoint telemetry, phase 2. Physical disk health from two sources, NIC link
state, boot type, Defender health, processor/memory/chassis inventory, the
listening TCP set, mapped network drives and Windows Update history. Five
additive migrations, 0012 through 0016, off the 0011 head.
* Mapped drives are collected by a probe running in the interactive user's
session, because they do not exist in the agent's session 0. Two Windows
sources can disagree about one drive and the worse status wins, so a
half-broken share is never rendered as healthy.
* Windows events are reported once each, keyed on the channel's own record id,
instead of being re-sent whenever a report overlapped the last one.
* PC Detail rework: Details cards flow, the hand-written Network card gives way
to a full address history, Storage and disk health merge into one card with
two sections, Operator Reports and Corporate Tools get their own tabs.
* An opt-in restic backup overlay with a third container, restore gated on the
dump's own data revision rather than the code head, and backup/restore history.
* The agent's version is read from the binary's PE version rather than trusted
from the csproj, and the Fleet view gains an Agent Version column.
* The enqueue route now asks caller_holds instead of the owner's permissions,
which closes the scoped-token elevation on run_script, update_app and
system_restart.
Held back deliberately:
* agent/installer/install-shopdbagent.ps1 stays deleted, as upstream deleted it.
The installer that reaches a bay is generated by _install_script() in
plugins/endpoints/api/siteagent.py; the standalone script had stopped being
shipped and was collecting fixes no endpoint ever ran.
* The .gitignore hunk adding docs/*.md. Correct on the publication lineage,
where docs/ becomes the wiki and its absence is how the gates detect a
published checkout. Here it would silently ignore every new doc.
* Their deletions of docs/DEV-BUILD-TEST.md and docs/PLUGIN-HOOKS.md. Upstream
those were stubs; here PLUGIN-HOOKS.md is the 31KB document CLAUDE.md points
readers at.
* Five zero-byte files committed by an accidental `git add -A`, a run log from
the author's own machine, and docs/notes/, which is working scratch that says
so in its own header and belongs in devnotes/ on this side.
Carried with the import so it lands green: the 17 new routes are described in
docs/api-inventory.json with the spec and project map regenerated, and two new
frontend specs read fixtures through fileURLToPath(URL) which node rejects under
jsdom's own URL global.
Suite 2591 passed / 9 skipped; frontend 391 passed; naming, style and doc gates
clean.
Scope: product
990 lines
35 KiB
Vue
990 lines
35 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>Endpoints</h2>
|
|
</div>
|
|
|
|
<p class="setting-description">
|
|
Manage how shop-floor endpoint agents authenticate to this server, build
|
|
the agent for this site, and set what it reports and how often.
|
|
</p>
|
|
|
|
<!-- ====================================================================
|
|
Key management
|
|
================================================================= -->
|
|
<section class="settings-section">
|
|
<h3>Key management</h3>
|
|
<p class="key-warning">
|
|
STORE YOUR KEY SECURELY. IT IS SHOWN ONLY ONCE AND CANNOT BE RECOVERED.
|
|
</p>
|
|
<p class="field-hint">
|
|
Before you generate a key, open the <strong>ShopDB Secure Send</strong>
|
|
tool in a second window. As soon as the key appears, paste it into
|
|
Secure Send, generate a secure link, and send that link to yourself in
|
|
Teams so the site leader has a one-time, self-destructing backup. Never
|
|
paste the raw key into a chat message or a plain file.
|
|
</p>
|
|
<p class="field-hint">
|
|
The endpoint agent reports with a single managed key scoped to endpoint
|
|
telemetry only. It is separate from every other collector, so rotating
|
|
or revoking it stops all endpoint-agent traffic immediately without
|
|
touching the PC or GE-Enforce collectors.
|
|
</p>
|
|
|
|
<div class="card form-card">
|
|
<div v-if="message" class="settings-success">{{ message }}</div>
|
|
<div v-if="error" class="error-message">{{ error }}</div>
|
|
|
|
<div v-if="loading" class="field-hint">Loading...</div>
|
|
|
|
<template v-else>
|
|
<div class="form-group">
|
|
<label>Status</label>
|
|
<p v-if="key" class="field-hint">
|
|
Active key <code class="mono">{{ key.tokenprefix }}...</code>,
|
|
created {{ formatDate(key.createddate) }}.
|
|
<template v-if="key.lastusedat">
|
|
Last used {{ formatDate(key.lastusedat) }}.
|
|
</template>
|
|
<template v-else>Not used yet.</template>
|
|
</p>
|
|
<p v-else class="field-hint">
|
|
No endpoint agent key is active. Generate one to let agents report
|
|
with a managed credential.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Bootstrap .env fallback</label>
|
|
<p class="field-hint">
|
|
<template v-if="envkeyfallback">
|
|
<strong>On.</strong> The bootstrap COLLECTOR_API_KEY still
|
|
authorizes endpoint reports. Generating a key turns this off so
|
|
the managed key becomes the only credential.
|
|
</template>
|
|
<template v-else>
|
|
<strong>Off.</strong> Only the managed key above authorizes
|
|
endpoint reports, so revoking it is an instant kill switch.
|
|
</template>
|
|
</p>
|
|
</div>
|
|
|
|
<div class="button-row">
|
|
<button class="btn btn-primary" @click="regenerate"
|
|
:disabled="busy">
|
|
{{ busy ? 'Working...' : (key ? 'Regenerate key' : 'Generate key') }}
|
|
</button>
|
|
<button v-if="key" class="btn btn-danger" @click="revoke"
|
|
:disabled="busy">
|
|
Revoke key
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-if="secret" class="card form-card secret-card">
|
|
<h4>Save this key now</h4>
|
|
<p class="field-hint">
|
|
Anyone with this key can report as an endpoint agent for this site.
|
|
Treat it like a password. If it is lost you must regenerate, which
|
|
revokes the old key and forces every agent to be reconfigured.
|
|
</p>
|
|
<p class="field-hint">
|
|
<strong>Recommended:</strong> before you leave this page, open the
|
|
<strong>ShopDB Secure Send</strong> tool in a second window, paste
|
|
this key, generate a secure link, and send that link to yourself in
|
|
Teams. That gives the site leader a one-time, self-destructing copy to
|
|
fall back on instead of pasting the raw key into a chat or a file.
|
|
</p>
|
|
<p class="field-hint">
|
|
Configure it as the agent <code>X-API-Key</code> header value.
|
|
</p>
|
|
<p class="mono secret-value">{{ secret }}</p>
|
|
<button class="btn btn-secondary" @click="copySecret">
|
|
{{ copied ? 'Copied' : 'Copy to clipboard' }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="card form-card">
|
|
<details class="collapsible">
|
|
<summary><h4>How it works</h4></summary>
|
|
<ul class="field-hint info-list">
|
|
<li>
|
|
<strong>Generate</strong> mints a fresh key and, on the first
|
|
generation, turns off the bootstrap <code>.env</code> fallback so
|
|
the managed key becomes the only accepted credential. The full
|
|
secret is shown once - store it in the agent config as the
|
|
<code>X-API-Key</code> header.
|
|
</li>
|
|
<li>
|
|
<strong>Regenerate</strong> is a hard cutover: the previous key is
|
|
revoked in the same moment the new one is minted. Every deployed
|
|
agent must be reconfigured with the new key before it can report
|
|
again.
|
|
</li>
|
|
<li>
|
|
<strong>Revoke</strong> deactivates the key and leaves it off. All
|
|
endpoint-agent reporting stops until you generate a new key. This is
|
|
the kill switch.
|
|
</li>
|
|
</ul>
|
|
</details>
|
|
|
|
<details class="collapsible">
|
|
<summary><h4>When to rotate</h4></summary>
|
|
<ul class="field-hint info-list">
|
|
<li>
|
|
<strong>An installer or agent left the secure environment.</strong>
|
|
If a build carrying the key is copied off the shop floor - onto an
|
|
engineer's laptop, a USB stick, or a machine that leaves the site -
|
|
treat the key as compromised and regenerate immediately.
|
|
</li>
|
|
<li>
|
|
<strong>A device is lost, stolen, or decommissioned</strong> while
|
|
still holding the key, and you cannot confirm the key was wiped.
|
|
</li>
|
|
<li>
|
|
<strong>Staff offboarding.</strong> Someone who could read the key
|
|
from a config or build pipeline is leaving; rotate as part of the
|
|
offboarding checklist.
|
|
</li>
|
|
<li>
|
|
<strong>Scheduled hygiene.</strong> Rotate on a routine cadence
|
|
(for example quarterly) so a silently leaked key has a bounded
|
|
lifetime.
|
|
</li>
|
|
<li>
|
|
<strong>Suspicious traffic.</strong> The Fleet view shows reports
|
|
from a host you do not recognize - revoke now, investigate, then
|
|
regenerate and redeploy to known-good endpoints.
|
|
</li>
|
|
</ul>
|
|
</details>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ====================================================================
|
|
Adoption tool
|
|
================================================================= -->
|
|
<section class="settings-section">
|
|
<h3>Adoption tool</h3>
|
|
<div class="card form-card">
|
|
<p class="field-hint">
|
|
Generate a one-shot, agentless enrollment tool. Run it once on a PC and
|
|
it creates a ShopDB PC and Endpoint entry using only the collector key -
|
|
no service, no polling, no ongoing telemetry. Use this for machines you
|
|
want inventoried without running the full endpoint agent.
|
|
</p>
|
|
<p class="field-hint">
|
|
Paste a collector key below (generate one above first; the secret is
|
|
only shown once). The key is embedded in the downloaded config and is
|
|
not stored on the server.
|
|
</p>
|
|
|
|
<label class="field-label" for="adopt-baseurl">Server base URL</label>
|
|
<input
|
|
id="adopt-baseurl"
|
|
v-model="adoptbaseurl"
|
|
class="form-control"
|
|
type="text"
|
|
placeholder="https://shopdb.example"
|
|
/>
|
|
|
|
<label class="field-label" for="adopt-key">Collector key</label>
|
|
<input
|
|
id="adopt-key"
|
|
v-model="adoptcollectorkey"
|
|
class="form-control mono"
|
|
type="text"
|
|
placeholder="Paste the endpoint agent key"
|
|
/>
|
|
|
|
<div class="button-row">
|
|
<button
|
|
class="btn btn-primary"
|
|
type="button"
|
|
:disabled="adoptbusy || !adoptbaseurl || !adoptcollectorkey"
|
|
@click="downloadAdoptionTool"
|
|
>
|
|
{{ adoptbusy ? 'Building...' : 'Download adoption tool' }}
|
|
</button>
|
|
</div>
|
|
<p v-if="adoptmessage" class="field-hint">{{ adoptmessage }}</p>
|
|
<p v-if="adopterror" class="field-error">{{ adopterror }}</p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ====================================================================
|
|
Build site agent
|
|
================================================================= -->
|
|
<section class="settings-section">
|
|
<h3>Build site agent</h3>
|
|
<div class="card form-card">
|
|
<p class="field-hint">
|
|
Produce an instance-locked agent package stamped with this server's
|
|
URL, the endpoint agent key, and the reporting cadence, so it deploys
|
|
with no manual configuration on the client. The download includes the
|
|
agent, a non-secret <code>appsettings.json</code>, and an
|
|
<code>install.ps1</code> that writes the credentials to the registry
|
|
(<code>HKLM\SOFTWARE\GE\ShopDB</code>) - the key never lands in
|
|
appsettings.
|
|
</p>
|
|
<p v-if="!agentstaged" class="field-hint">
|
|
<strong>No agent binary is staged on this server.</strong> The package
|
|
will still build with config and installer, but no agent will ship
|
|
inside it. Upload a current release in the binary section below to
|
|
have it included in every download.
|
|
</p>
|
|
|
|
<label class="field-label" for="agent-baseurl">Server base URL</label>
|
|
<input
|
|
id="agent-baseurl"
|
|
v-model="agentbaseurl"
|
|
class="form-control"
|
|
type="text"
|
|
placeholder="https://shopdb.example"
|
|
/>
|
|
|
|
<label class="field-label" for="agent-key">Endpoint agent key</label>
|
|
<input
|
|
id="agent-key"
|
|
v-model="agentcollectorkey"
|
|
class="form-control mono"
|
|
type="text"
|
|
placeholder="Paste the endpoint agent key generated above"
|
|
/>
|
|
|
|
<label class="field-label" for="agent-cadence">
|
|
Reporting cadence (seconds)
|
|
</label>
|
|
<input
|
|
id="agent-cadence"
|
|
v-model.number="agentcadenceseconds"
|
|
class="form-control"
|
|
type="number"
|
|
min="60"
|
|
/>
|
|
|
|
<label v-if="certavailable" class="checkbox-row">
|
|
<input v-model="agentbundlecert" type="checkbox" />
|
|
<span>
|
|
Bundle this server's TLS certificate and trust it on the endpoint.
|
|
Enable only for self-signed or internal-CA servers; leave off when
|
|
the server uses a publicly trusted certificate.
|
|
</span>
|
|
</label>
|
|
|
|
<label class="field-label" for="agent-cert">
|
|
Server TLS certificate (optional)
|
|
</label>
|
|
<input
|
|
id="agent-cert"
|
|
ref="agentcertinput"
|
|
class="form-control"
|
|
type="file"
|
|
accept=".crt,.pem,.cer"
|
|
@change="onAgentCertChange"
|
|
/>
|
|
<p class="field-hint">
|
|
For a reverse-proxy or internal-CA server this app cannot see, upload
|
|
the server's public certificate (or issuing CA) in PEM format. It is
|
|
validated, bundled with the package, and imported into the endpoint's
|
|
Trusted Root store by <code>install.ps1</code>. Leave empty when the
|
|
server uses a publicly trusted certificate. An upload takes precedence
|
|
over the bundled server certificate above.
|
|
</p>
|
|
|
|
<div class="button-row">
|
|
<button
|
|
class="btn btn-primary"
|
|
type="button"
|
|
:disabled="agentbusy || !agentbaseurl || !agentcollectorkey"
|
|
@click="buildSiteAgent"
|
|
>
|
|
{{ agentbusy ? 'Building...' : 'Build site agent' }}
|
|
</button>
|
|
</div>
|
|
<p v-if="agentmessage" class="field-hint">{{ agentmessage }}</p>
|
|
<p v-if="agenterror" class="field-error">{{ agenterror }}</p>
|
|
|
|
<!-- ================================================================
|
|
Agent binary versions: upload a new build, keep the last few,
|
|
and roll the current pointer back to a retained version. Staging
|
|
only changes what a FUTURE download contains; it does not touch
|
|
any running endpoint. Pushing to live endpoints is a separate,
|
|
heavier grant handled elsewhere. Collapsed by default - binary
|
|
management is infrequent next to building a package. -->
|
|
<details class="collapsible binary-manager">
|
|
<summary><h4>Upload New/Legacy Agents</h4></summary>
|
|
<div
|
|
v-for="binary in binaries"
|
|
:key="binary.key"
|
|
class="card binary-block"
|
|
>
|
|
<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.
|
|
</p>
|
|
<p class="field-hint">
|
|
Current staged version:
|
|
<strong>{{ binary.current || 'none staged yet - upload a build below' }}</strong>
|
|
</p>
|
|
|
|
<!-- Current release upload. The version is auto-detected from the
|
|
EXE's PE version resource on select; the operator can override.
|
|
This upload becomes the current staged build. -->
|
|
<label class="field-label">
|
|
Upload {{ binary.name }} binary current release
|
|
</label>
|
|
<div class="binary-upload">
|
|
<input
|
|
class="form-control"
|
|
type="file"
|
|
accept=".exe"
|
|
@change="onBinaryFileChange(binary.key, $event)"
|
|
/>
|
|
<input
|
|
v-model="binary.version"
|
|
class="form-control"
|
|
type="text"
|
|
:placeholder="binary.detecting
|
|
? 'Reading version...'
|
|
: 'Version, auto-detected from EXE'"
|
|
/>
|
|
<button
|
|
class="btn btn-secondary"
|
|
type="button"
|
|
:disabled="binary.busy || binary.detecting || !binary.file"
|
|
@click="uploadBinary(binary.key)"
|
|
>
|
|
{{ binary.busy ? 'Uploading...' : 'Upload current release' }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Rollback / legacy path. Stages a build into the version list
|
|
without displacing the current release so it is available to
|
|
roll back to. WARNING copy per intent: the version must be
|
|
confirmed from the EXE properties before uploading. -->
|
|
<label class="field-label">
|
|
Upload legacy build for rollback
|
|
</label>
|
|
<p class="field-warning">
|
|
The version is read from the EXE itself. Leave this blank to use the
|
|
binary's own file version, or enter a dotted-numeric override only
|
|
when the build carries no readable version.
|
|
</p>
|
|
<div class="binary-upload">
|
|
<input
|
|
class="form-control"
|
|
type="file"
|
|
accept=".exe"
|
|
@change="onLegacyFileChange(binary.key, $event)"
|
|
/>
|
|
<input
|
|
v-model="binary.legacyversion"
|
|
class="form-control"
|
|
type="text"
|
|
placeholder="Version, e.g. 1.5.0"
|
|
/>
|
|
<button
|
|
class="btn btn-secondary"
|
|
type="button"
|
|
:disabled="binary.busy || !binary.legacyfile"
|
|
@click="uploadLegacy(binary.key)"
|
|
>
|
|
{{ binary.busy ? 'Uploading...' : 'Upload legacy build' }}
|
|
</button>
|
|
</div>
|
|
<p v-if="binary.message" class="field-hint">{{ binary.message }}</p>
|
|
<p v-if="binary.error" class="field-error">{{ binary.error }}</p>
|
|
|
|
<!-- Version selector: pick the staged build shipped by the next
|
|
download. Ordered by version number (highest first). -->
|
|
<div v-if="binary.versions.length" class="binary-upload">
|
|
<label class="field-label" :for="`select-${binary.key}`">
|
|
Active version
|
|
</label>
|
|
<select
|
|
:id="`select-${binary.key}`"
|
|
class="form-control"
|
|
:value="binary.current || ''"
|
|
:disabled="binary.busy"
|
|
@change="setCurrentBinary(binary.key, $event.target.value)"
|
|
>
|
|
<option
|
|
v-for="entry in binary.versions"
|
|
:key="entry.version"
|
|
:value="entry.version"
|
|
>
|
|
{{ entry.version }}{{ entry.iscurrent ? ' (current)' : '' }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div v-if="binary.versions.length" class="previous-versions">
|
|
<button
|
|
class="btn-link"
|
|
type="button"
|
|
@click="binary.showprevious = !binary.showprevious"
|
|
>
|
|
{{ binary.showprevious ? 'Hide' : 'Show' }} staged versions
|
|
({{ binary.versions.length }})
|
|
</button>
|
|
<table v-if="binary.showprevious" class="versions-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Version</th>
|
|
<th>Uploaded</th>
|
|
<th>Size</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="entry in binary.versions" :key="entry.version">
|
|
<td class="mono">{{ entry.version }}</td>
|
|
<td>{{ formatDate(entry.uploadedat) }}</td>
|
|
<td>{{ formatBytes(entry.size) }}</td>
|
|
<td>
|
|
<span v-if="entry.iscurrent" class="current-tag">current</span>
|
|
<button
|
|
v-else
|
|
class="btn-link"
|
|
type="button"
|
|
:disabled="binary.busy"
|
|
@click="setCurrentBinary(binary.key, entry.version)"
|
|
>
|
|
Make current
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ====================================================================
|
|
Agent configuration
|
|
================================================================= -->
|
|
<section class="settings-section">
|
|
<h3>Agent configuration</h3>
|
|
<div class="card form-card">
|
|
<p class="field-hint">
|
|
Define what the agent collects and how often: reporting cadence,
|
|
monitored processes and services, the curated Windows event list, and
|
|
whether unknown hosts self-adopt on first contact. These settings are
|
|
stamped into the build above so a deployed agent needs no local edits.
|
|
</p>
|
|
<p class="field-hint">
|
|
Monitored processes and services are defined as reusable monitors and
|
|
attached to PC types through site policies, so a deployed agent fetches
|
|
the right set for its type.
|
|
</p>
|
|
<button
|
|
class="btn btn-secondary"
|
|
type="button"
|
|
@click="editConfiguration"
|
|
>
|
|
Edit configuration
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import api from '@/api'
|
|
|
|
const router = useRouter()
|
|
const loading = ref(true)
|
|
const busy = ref(false)
|
|
const key = ref(null)
|
|
const envkeyfallback = ref(true)
|
|
const secret = ref('')
|
|
const copied = ref(false)
|
|
const message = ref('')
|
|
const error = ref('')
|
|
|
|
// Adoption tool: build a one-shot, agentless enrollment download.
|
|
const adoptbaseurl = ref('')
|
|
const adoptcollectorkey = ref('')
|
|
const adoptbusy = ref(false)
|
|
const adoptmessage = ref('')
|
|
const adopterror = ref('')
|
|
|
|
// Site agent generator: build an instance-locked, resident endpoint agent.
|
|
const agentbaseurl = ref('')
|
|
const agentcollectorkey = ref('')
|
|
const agentcadenceseconds = ref(900)
|
|
const agentstaged = ref(false)
|
|
const certavailable = ref(false)
|
|
const agentbundlecert = ref(false)
|
|
const agentcertinput = ref(null)
|
|
const agentcertfile = ref(null)
|
|
const agentbusy = ref(false)
|
|
const agentmessage = ref('')
|
|
const agenterror = ref('')
|
|
|
|
// Versioned agent/tray binaries. Each block owns its own upload form, retained
|
|
// version list, and busy/message state so the two rows act independently. The
|
|
// 'key' matches the backend binkey (agent | tray).
|
|
const binaries = ref([
|
|
{ key: 'agent', label: 'ShopDB Agent (shopdbagent.exe)',
|
|
name: 'ShopDB Agent',
|
|
current: null, versions: [], version: '', file: '', detecting: false,
|
|
legacyversion: '', legacyfile: '',
|
|
busy: false, message: '', error: '', showprevious: false },
|
|
{ key: 'tray', label: 'Tray Agent (shopdb-endpoint-tray.exe)',
|
|
name: 'Tray Agent',
|
|
current: null, versions: [], version: '', file: '', detecting: false,
|
|
legacyversion: '', legacyfile: '',
|
|
busy: false, message: '', error: '', showprevious: false },
|
|
])
|
|
|
|
function binaryFor(binkey) {
|
|
return binaries.value.find((entry) => entry.key === binkey)
|
|
}
|
|
|
|
function formatBytes(value) {
|
|
if (!value && value !== 0) return ''
|
|
if (value < 1024) return value + ' B'
|
|
if (value < 1024 * 1024) return (value / 1024).toFixed(1) + ' KB'
|
|
return (value / (1024 * 1024)).toFixed(1) + ' MB'
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return ''
|
|
const parsed = new Date(value)
|
|
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString()
|
|
}
|
|
|
|
function filenameFromResponse(response, fallback) {
|
|
const disposition = response.headers?.['content-disposition'] || ''
|
|
const match = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition)
|
|
return match ? decodeURIComponent(match[1]) : fallback
|
|
}
|
|
|
|
function applyStatus(data) {
|
|
key.value = data.key || null
|
|
envkeyfallback.value = !!data.envkeyfallback
|
|
}
|
|
|
|
onMounted(async () => {
|
|
adoptbaseurl.value = window.location.origin
|
|
agentbaseurl.value = window.location.origin
|
|
try {
|
|
const response = await api.get('/endpoints/agentkey')
|
|
applyStatus(response.data.data || {})
|
|
} catch (loadError) {
|
|
error.value = loadError.response?.status === 403
|
|
? 'Only an administrator can manage the endpoint agent key'
|
|
: 'Could not load the endpoint agent key status'
|
|
console.error(loadError)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
try {
|
|
const response = await api.get('/endpoints/siteagent')
|
|
const status = response.data.data || {}
|
|
agentstaged.value = !!status.agentstaged
|
|
certavailable.value = !!status.certavailable
|
|
agentbundlecert.value = !!status.certavailable
|
|
applyBinaryStatus('agent', status.agentcurrent, status.agentversions)
|
|
applyBinaryStatus('tray', status.traycurrent, status.trayversions)
|
|
} catch (statusError) {
|
|
console.error(statusError)
|
|
}
|
|
})
|
|
|
|
function applyBinaryStatus(binkey, current, versions) {
|
|
const binary = binaryFor(binkey)
|
|
if (!binary) return
|
|
binary.current = current || null
|
|
binary.versions = Array.isArray(versions) ? versions : []
|
|
}
|
|
|
|
async function regenerate() {
|
|
if (key.value &&
|
|
!window.confirm('Regenerate the key? The current key stops working '
|
|
+ 'immediately and every agent must be reconfigured.')) {
|
|
return
|
|
}
|
|
busy.value = true
|
|
message.value = ''
|
|
error.value = ''
|
|
secret.value = ''
|
|
copied.value = false
|
|
try {
|
|
const response = await api.post('/endpoints/agentkey/regenerate')
|
|
const data = response.data.data || {}
|
|
applyStatus(data)
|
|
secret.value = data.secret || ''
|
|
if (data.secret) {
|
|
adoptcollectorkey.value = data.secret
|
|
agentcollectorkey.value = data.secret
|
|
}
|
|
message.value = 'Endpoint agent key generated.'
|
|
} catch (regenError) {
|
|
error.value = regenError.response?.data?.data?.error?.message
|
|
|| 'Could not generate the endpoint agent key'
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
async function revoke() {
|
|
if (!window.confirm('Revoke the key? All endpoint-agent reporting stops '
|
|
+ 'until a new key is generated.')) {
|
|
return
|
|
}
|
|
busy.value = true
|
|
message.value = ''
|
|
error.value = ''
|
|
secret.value = ''
|
|
try {
|
|
const response = await api.delete('/endpoints/agentkey')
|
|
applyStatus(response.data.data || {})
|
|
message.value = 'Endpoint agent key revoked.'
|
|
} catch (revokeError) {
|
|
error.value = revokeError.response?.data?.data?.error?.message
|
|
|| 'Could not revoke the endpoint agent key'
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
async function copySecret() {
|
|
try {
|
|
await navigator.clipboard.writeText(secret.value)
|
|
copied.value = true
|
|
} catch (copyError) {
|
|
console.error(copyError)
|
|
}
|
|
}
|
|
|
|
async function downloadAdoptionTool() {
|
|
adoptbusy.value = true
|
|
adoptmessage.value = ''
|
|
adopterror.value = ''
|
|
try {
|
|
const response = await api.post(
|
|
'/endpoints/adoptiontool/package',
|
|
{ baseurl: adoptbaseurl.value, collectorkey: adoptcollectorkey.value },
|
|
{ responseType: 'blob' })
|
|
const url = window.URL.createObjectURL(response.data)
|
|
const link = document.createElement('a')
|
|
link.href = url
|
|
link.download = 'shopdb-adoption-tool.zip'
|
|
document.body.appendChild(link)
|
|
link.click()
|
|
link.remove()
|
|
window.URL.revokeObjectURL(url)
|
|
adoptmessage.value = 'Adoption tool downloaded.'
|
|
} catch (downloadError) {
|
|
adopterror.value = downloadError.response?.status === 403
|
|
? 'Only an administrator can build the adoption tool'
|
|
: 'Could not build the adoption tool'
|
|
console.error(downloadError)
|
|
} finally {
|
|
adoptbusy.value = false
|
|
}
|
|
}
|
|
|
|
function onAgentCertChange(event) {
|
|
const files = event.target.files
|
|
agentcertfile.value = files && files.length ? files[0] : null
|
|
}
|
|
|
|
async function readBlobError(blob) {
|
|
try {
|
|
const parsed = JSON.parse(await blob.text())
|
|
return parsed.data?.error?.message || parsed.message
|
|
|| 'Could not build the site agent'
|
|
} catch (parseError) {
|
|
return 'Could not build the site agent'
|
|
}
|
|
}
|
|
|
|
async function buildSiteAgent() {
|
|
agentbusy.value = true
|
|
agentmessage.value = ''
|
|
agenterror.value = ''
|
|
try {
|
|
const form = new FormData()
|
|
form.append('baseurl', agentbaseurl.value)
|
|
form.append('collectorkey', agentcollectorkey.value)
|
|
form.append('cadenceseconds', agentcadenceseconds.value)
|
|
form.append('bundlecert', agentbundlecert.value ? 'true' : 'false')
|
|
if (agentcertfile.value) {
|
|
form.append('certfile', agentcertfile.value)
|
|
}
|
|
const response = await api.post(
|
|
'/endpoints/siteagent/package',
|
|
form,
|
|
{ responseType: 'blob' })
|
|
const url = window.URL.createObjectURL(response.data)
|
|
const link = document.createElement('a')
|
|
link.href = url
|
|
// Honor the server's versioned download name (shopdb-endpoint-agent-<ver>.zip)
|
|
// from Content-Disposition so operators see the agent version in the file
|
|
// name; fall back to the fixed name when the header is absent.
|
|
link.download = filenameFromResponse(response, 'shopdb-endpoint-agent.zip')
|
|
document.body.appendChild(link)
|
|
link.click()
|
|
link.remove()
|
|
window.URL.revokeObjectURL(url)
|
|
agentmessage.value = 'Site agent built.'
|
|
} catch (buildError) {
|
|
if (buildError.response?.status === 400 && buildError.response?.data) {
|
|
agenterror.value = await readBlobError(buildError.response.data)
|
|
} else {
|
|
agenterror.value = buildError.response?.status === 403
|
|
? 'Only an administrator can build the site agent'
|
|
: 'Could not build the site agent'
|
|
}
|
|
console.error(buildError)
|
|
} finally {
|
|
agentbusy.value = false
|
|
}
|
|
}
|
|
|
|
async function detectVersion(binkey, file) {
|
|
const form = new FormData()
|
|
form.append('file', file)
|
|
const response = await api.post(
|
|
`/endpoints/siteagent/binaries/${binkey}/detect`, form,
|
|
{ headers: { 'Content-Type': 'multipart/form-data' } })
|
|
return response.data.data?.version || ''
|
|
}
|
|
|
|
function onBinaryFileChange(binkey, event) {
|
|
const binary = binaryFor(binkey)
|
|
if (!binary) return
|
|
const files = event.target.files
|
|
binary.file = files && files.length ? files[0] : ''
|
|
binary.error = ''
|
|
if (!binary.file) return
|
|
binary.detecting = true
|
|
detectVersion(binkey, binary.file)
|
|
.then((version) => {
|
|
if (version) binary.version = version
|
|
})
|
|
.catch((detectError) => {
|
|
console.error(detectError)
|
|
})
|
|
.finally(() => {
|
|
binary.detecting = false
|
|
})
|
|
}
|
|
|
|
function onLegacyFileChange(binkey, event) {
|
|
const binary = binaryFor(binkey)
|
|
if (!binary) return
|
|
const files = event.target.files
|
|
binary.legacyfile = files && files.length ? files[0] : ''
|
|
}
|
|
|
|
async function uploadBinary(binkey) {
|
|
const binary = binaryFor(binkey)
|
|
if (!binary || !binary.file) return
|
|
binary.busy = true
|
|
binary.message = ''
|
|
binary.error = ''
|
|
try {
|
|
const form = new FormData()
|
|
form.append('version', binary.version)
|
|
form.append('file', binary.file)
|
|
const response = await api.post(
|
|
`/endpoints/siteagent/binaries/${binkey}`, form,
|
|
{ headers: { 'Content-Type': 'multipart/form-data' } })
|
|
const data = response.data.data || {}
|
|
applyBinaryStatus(binkey, data.current, data.versions)
|
|
binary.version = ''
|
|
binary.file = ''
|
|
binary.message = 'Uploaded and set as current.'
|
|
} catch (uploadError) {
|
|
binary.error = uploadError.response?.data?.data?.error?.message
|
|
|| (uploadError.response?.status === 403
|
|
? 'You do not have permission to upload agent binaries'
|
|
: 'Could not upload the binary')
|
|
console.error(uploadError)
|
|
} finally {
|
|
binary.busy = false
|
|
}
|
|
}
|
|
|
|
async function uploadLegacy(binkey) {
|
|
const binary = binaryFor(binkey)
|
|
if (!binary || !binary.legacyfile) return
|
|
binary.busy = true
|
|
binary.message = ''
|
|
binary.error = ''
|
|
try {
|
|
const form = new FormData()
|
|
form.append('version', binary.legacyversion)
|
|
form.append('file', binary.legacyfile)
|
|
form.append('makecurrent', 'false')
|
|
const response = await api.post(
|
|
`/endpoints/siteagent/binaries/${binkey}`, form,
|
|
{ headers: { 'Content-Type': 'multipart/form-data' } })
|
|
const data = response.data.data || {}
|
|
applyBinaryStatus(binkey, data.current, data.versions)
|
|
binary.legacyversion = ''
|
|
binary.legacyfile = ''
|
|
binary.message = 'Legacy build staged for rollback.'
|
|
} catch (uploadError) {
|
|
binary.error = uploadError.response?.data?.data?.error?.message
|
|
|| (uploadError.response?.status === 403
|
|
? 'You do not have permission to upload agent binaries'
|
|
: 'Could not upload the legacy build')
|
|
console.error(uploadError)
|
|
} finally {
|
|
binary.busy = false
|
|
}
|
|
}
|
|
|
|
async function setCurrentBinary(binkey, version) {
|
|
const binary = binaryFor(binkey)
|
|
if (!binary) return
|
|
if (!window.confirm(`Make version ${version} the current staged build? `
|
|
+ 'New downloads will ship this version.')) {
|
|
return
|
|
}
|
|
binary.busy = true
|
|
binary.message = ''
|
|
binary.error = ''
|
|
try {
|
|
const response = await api.put(
|
|
`/endpoints/siteagent/binaries/${binkey}/current`, { version })
|
|
const data = response.data.data || {}
|
|
applyBinaryStatus(binkey, data.current, data.versions)
|
|
binary.message = `Version ${version} is now current.`
|
|
} catch (setError) {
|
|
binary.error = setError.response?.data?.data?.error?.message
|
|
|| 'Could not change the current version'
|
|
console.error(setError)
|
|
} finally {
|
|
binary.busy = false
|
|
}
|
|
}
|
|
|
|
function editConfiguration() {
|
|
router.push({ name: 'endpoints-monitors-settings' })
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.mono { font-family: monospace; word-break: break-all; }
|
|
.form-card h3 { margin-top: 0; }
|
|
.form-card h4 { margin-top: 1rem; margin-bottom: 0.25rem; }
|
|
.form-card h4:first-child { margin-top: 0; }
|
|
.button-row { display: flex; gap: 0.5rem; }
|
|
.checkbox-row {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: flex-start;
|
|
margin: 0.75rem 0;
|
|
font-size: 0.9rem;
|
|
}
|
|
.checkbox-row input { margin-top: 0.2rem; }
|
|
.secret-card { border: 1px solid var(--color-warning, #b8860b); }
|
|
.key-warning {
|
|
margin: 0 0 0.75rem;
|
|
padding: 0.6rem 0.75rem;
|
|
border: 2px solid #ff0000;
|
|
border-radius: 4px;
|
|
background: rgba(255, 0, 0, 0.12);
|
|
color: #ff2d2d;
|
|
font-size: 1.25rem;
|
|
font-weight: 800;
|
|
letter-spacing: 0.02em;
|
|
text-align: center;
|
|
}
|
|
.secret-value { padding: 0.5rem; background: var(--color-surface-2, #1e1e1e); }
|
|
.settings-section { margin-bottom: 2rem; }
|
|
.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: 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;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
margin: 0.5rem 0;
|
|
}
|
|
.binary-upload .form-control[type="text"] { max-width: 12rem; }
|
|
.previous-versions { margin-top: 0.5rem; }
|
|
.collapsible { margin: 0.5rem 0; }
|
|
.collapsible > summary {
|
|
cursor: pointer;
|
|
list-style: none;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
user-select: none;
|
|
}
|
|
.collapsible > summary::-webkit-details-marker { display: none; }
|
|
.collapsible > summary::before {
|
|
content: "\25be";
|
|
display: inline-block;
|
|
transform: rotate(-90deg);
|
|
transition: transform 0.15s ease;
|
|
color: var(--color-text-muted, #888);
|
|
}
|
|
.collapsible[open] > summary::before { transform: rotate(0deg); }
|
|
.collapsible > summary h4 { margin: 0; }
|
|
.field-warning {
|
|
margin: 0.25rem 0 0.5rem;
|
|
padding: 0.5rem 0.75rem;
|
|
border-left: 3px solid var(--color-warning, #b8860b);
|
|
background: rgba(184, 134, 11, 0.08);
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
}
|
|
.btn-link {
|
|
background: none;
|
|
border: none;
|
|
color: var(--color-link, #4ea1ff);
|
|
cursor: pointer;
|
|
padding: 0;
|
|
font-size: 0.9rem;
|
|
text-decoration: underline;
|
|
}
|
|
.btn-link:disabled { color: var(--color-text-muted, #888); cursor: default; }
|
|
.versions-table {
|
|
width: 100%;
|
|
margin-top: 0.5rem;
|
|
border-collapse: collapse;
|
|
font-size: 0.85rem;
|
|
}
|
|
.versions-table th,
|
|
.versions-table td {
|
|
text-align: left;
|
|
padding: 0.3rem 0.5rem;
|
|
border-bottom: 1px solid var(--color-border, #333);
|
|
}
|
|
.current-tag {
|
|
font-size: 0.8rem;
|
|
color: var(--color-success, #4caf50);
|
|
font-weight: 600;
|
|
}
|
|
</style>
|