Files
shopdb-flask/plugins/endpoints/frontend/views/AgentKeySettings.vue
cproudlock 70d5531b4d Make rollback, first upgrades and pin changes work as tested on Windows
Found in an end-to-end test on a Windows 11 VM against a self-signed server.

- Rollback: a 1.20 agent re-enrolls within a second of an un-enroll, so an
  older agent pushed afterwards arrived on an enrolled PC and was refused.
  Revoke now takes {"blockreenroll": true, "reason": ...}; a blocked PC is
  never issued a credential and keeps working on the shared key until
  POST .../assets/<assetid>/allow-reenroll. The Enrollment page offers the
  block when un-enrolling and shows blocked PCs with an allow button.
- First upgrade of old agents: agents before 1.20 check the server
  certificate when downloading the upgrade, so a store wave failed on PCs that
  do not trust a self-signed server while the Migration tab said ready. A
  failed download now marks the PC blocked with "server certificate not
  trusted by this PC". New Install-ShopDBServerCertificate.ps1 (GE-Enforce,
  SYSTEM) adds a non-CA server certificate to the trusted roots after checking
  its thumbprint; the server certificate page offers a .cer download and the
  SHA-1 thumbprint.
- The generated installer writes ServerPins (with an optional next pin for a
  planned rotation), can leave the shared key out so the PC waits for
  approval, never touches an existing credential, and skips the trusted-root
  import for a CA certificate.
- The agent re-reads server pins about every 60 seconds and recycles pooled
  connections within 2 minutes, so a pin change needs no restart.
- An alert can only be acknowledged by a PC it targets.
- A wave no longer counts a PC as failed after a later wave upgraded it.
- The scripts report a pin mismatch only when the pin really is missing, and
  otherwise log the real error.
- Runbook: trust step before the first wave, rollback order, pin re-read,
  verifying the pin against the live URL; agent contract updated.
2026-09-15 16:07:06 -04:00

1042 lines
36 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, server pin, optionally 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 settings 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="checkbox-row">
<input v-model="agentincludesharedkey" type="checkbox" />
<span>
Include shared key. When off, the package holds no key and each PC
requests enrollment for an admin to approve in Settings &gt;
Endpoints &gt; Enrollment.
</span>
</label>
<template v-if="agentincludesharedkey">
<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"
/>
</template>
<label class="field-label" for="agent-nextpin">
Next server pin (optional)
</label>
<input
id="agent-nextpin"
v-model.trim="agentnextserverpin"
class="form-control mono"
type="text"
maxlength="64"
placeholder="64 lowercase hex characters"
/>
<p class="field-hint">
Current server pin:
<code>{{ agentserverpin || 'not available (no server certificate found)' }}</code>.
Add the next certificate's pin only ahead of a planned certificate
rotation.
</p>
<p v-if="agentnextpininvalid()" class="field-error">
The next server pin must be exactly 64 lowercase hex characters.
</p>
<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 in PEM format. It is validated,
bundled with the package, pinned, and imported into the endpoint's
Trusted Root store by <code>install.ps1</code> (a CA certificate is
bundled but never imported or pinned). 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
|| (agentincludesharedkey && !agentcollectorkey)
|| agentnextpininvalid()"
@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('')
const agentincludesharedkey = ref(true)
const agentnextserverpin = ref('')
const agentserverpin = ref('')
function agentnextpininvalid() {
const pin = agentnextserverpin.value
return !!pin && !/^[0-9a-f]{64}$/.test(pin)
}
// 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
agentincludesharedkey.value = status.sharedkeyallowed !== false
agentserverpin.value = status.serverpin || ''
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)
if (agentincludesharedkey.value) {
form.append('collectorkey', agentcollectorkey.value)
}
form.append('includesharedkey', agentincludesharedkey.value ? 'true' : 'false')
if (agentnextserverpin.value) {
form.append('nextserverpin', agentnextserverpin.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>