main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
1458 lines
52 KiB
Vue
1458 lines
52 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>Endpoints</h2>
|
|
</div>
|
|
|
|
<p class="setting-description">
|
|
Manage the shared key PCs use until they enroll, build the agent for this
|
|
site, and set what it reports and how often. Each enrolled PC
|
|
authenticates with its own credential instead; see Settings > Endpoints
|
|
> Enrollment.
|
|
</p>
|
|
|
|
<!-- ====================================================================
|
|
Shared key
|
|
================================================================= -->
|
|
<section class="settings-section">
|
|
<h3>Shared key (used until PCs enroll)</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">
|
|
Every PC installed with the shared key holds this same key. It lets a
|
|
PC that has not enrolled yet report and ask for its own credential. An enrolled PC is refused it and
|
|
uses its credential instead, so rotating or revoking the key only
|
|
affects PCs that have not enrolled. It is scoped to endpoint telemetry
|
|
and separate from every other collector.
|
|
</p>
|
|
|
|
<div v-if="enrollment" class="card form-card enrollment-summary">
|
|
<div class="enrollment-counts">
|
|
<span class="enrollment-count">
|
|
<strong>{{ enrollment.enrolled }}</strong> of {{ enrollment.total }} PCs enrolled
|
|
</span>
|
|
<span class="enrollment-count">
|
|
<strong>{{ enrollment.notenrolled }}</strong> still on the shared key
|
|
</span>
|
|
<span class="enrollment-count">
|
|
<strong>{{ enrollment.requests }}</strong>
|
|
pending enrollment {{ enrollment.requests === 1 ? 'request' : 'requests' }}
|
|
</span>
|
|
</div>
|
|
<div class="progress-track">
|
|
<div class="progress-fill" :style="{ width: enrolledPercent + '%' }"></div>
|
|
</div>
|
|
<p class="field-hint">
|
|
<template v-if="!enrollment.sharedkeyallowed">
|
|
<strong>The shared key is switched off</strong> in Enrollment, so PCs
|
|
that have not enrolled already cannot report with it.
|
|
</template>
|
|
Approve requests, move PCs over and retire the shared key in
|
|
<router-link :to="{ name: 'endpoints-enrollment-settings' }">
|
|
Settings > Endpoints > Enrollment</router-link>.
|
|
</p>
|
|
</div>
|
|
|
|
<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 shared key is active. PCs that have not enrolled cannot report
|
|
or auto-enroll until one is generated.
|
|
</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 a not-yet-enrolled endpoint agent
|
|
for this site and ask to enroll. Treat it like a password. If it is
|
|
lost you must regenerate, which revokes the old key; PCs that have not
|
|
enrolled then need the new one.
|
|
</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>Until a PC enrolls</strong> it reports with this shared key.
|
|
Agents from 1.20 use it once more to ask for their own credential,
|
|
and from then on send that credential instead.
|
|
</li>
|
|
<li>
|
|
<strong>Enrolled PCs ignore it.</strong> The server refuses the shared
|
|
key for any PC that holds a credential, so regenerating or revoking
|
|
it does not touch them.
|
|
</li>
|
|
<li>
|
|
<strong>Generate</strong> mints a key and, the first time, turns off
|
|
the bootstrap <code>.env</code> fallback. The full secret is shown
|
|
once; it goes into the agent config as the <code>X-API-Key</code>
|
|
header.
|
|
</li>
|
|
<li>
|
|
<strong>Regenerate</strong> revokes the previous key in the same
|
|
moment. PCs that have not enrolled must be given the new key before
|
|
they can report or enroll again.
|
|
</li>
|
|
<li>
|
|
<strong>Revoke</strong> leaves no shared key. PCs that have not
|
|
enrolled stop reporting and can no longer auto-enroll; enrolled PCs
|
|
carry on.
|
|
</li>
|
|
</ul>
|
|
</details>
|
|
|
|
<details class="collapsible">
|
|
<summary><h4>When to rotate or retire</h4></summary>
|
|
<ul class="field-hint info-list">
|
|
<li>
|
|
<strong>At cutover.</strong> Once every PC has enrolled, switch the
|
|
shared key off in Enrollment, clear it from every place it lives,
|
|
then regenerate so any copy you missed is dead. The steps are in the
|
|
per-PC credentials rollout runbook.
|
|
</li>
|
|
<li>
|
|
<strong>An installer or key left the secure environment.</strong> A
|
|
build or file carrying the key copied onto a laptop, a USB stick or a
|
|
machine that leaves the site. Regenerate; only PCs that have not
|
|
enrolled need the new key.
|
|
</li>
|
|
<li>
|
|
<strong>Staff offboarding.</strong> Someone who could read the key
|
|
from a config or build pipeline is leaving.
|
|
</li>
|
|
<li>
|
|
<strong>Suspicious traffic.</strong> The Fleet view shows reports from
|
|
a host you do not recognize. Revoke now, investigate, then regenerate
|
|
for the PCs that still need it.
|
|
</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 >
|
|
Endpoints > 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-flagged
|
|
certificate is bundled and pinned but never imported, so only agents
|
|
1.20 and newer will trust the server). 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="agentwarning" class="field-warning">{{ agentwarning }}</p>
|
|
<p v-if="agenterror" class="field-error">{{ agenterror }}</p>
|
|
|
|
<!-- ================================================================
|
|
One-line install. An admin mints a short-lived token and hands a
|
|
tech one command; the token only fetches the installer. The
|
|
package it serves never carries the shared key, so the PC waits
|
|
in Pending until an admin approves its enrollment. -->
|
|
<div class="card bootstrap-block">
|
|
<h4>One-line install</h4>
|
|
<p class="field-hint">
|
|
Generate a short-lived token and give a technician the command.
|
|
On a PC with no agent, run it from an <strong>elevated</strong>
|
|
PowerShell. The token only fetches the installer: it carries no
|
|
shared key and provisions nothing on its own. A managed bay should
|
|
keep getting the agent from GE-Enforce instead.
|
|
</p>
|
|
|
|
<div class="bootstrap-inputs">
|
|
<div>
|
|
<label class="field-label" for="bootstrap-hours">
|
|
Valid for (hours)
|
|
</label>
|
|
<input
|
|
id="bootstrap-hours"
|
|
v-model.number="bootstraphours"
|
|
class="form-control"
|
|
type="number"
|
|
min="1"
|
|
max="168"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="field-label" for="bootstrap-maxuses">
|
|
Maximum uses
|
|
</label>
|
|
<input
|
|
id="bootstrap-maxuses"
|
|
v-model.number="bootstrapmaxuses"
|
|
class="form-control"
|
|
type="number"
|
|
min="1"
|
|
max="500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="button-row">
|
|
<button
|
|
class="btn btn-primary"
|
|
type="button"
|
|
:disabled="bootstrapbusy"
|
|
@click="generateBootstrapToken"
|
|
>
|
|
{{ bootstrapbusy ? 'Generating...' : 'Generate install command' }}
|
|
</button>
|
|
</div>
|
|
|
|
<template v-if="bootstrapcommand">
|
|
<p class="field-warning">
|
|
Copy this now. The token is shown once and cannot be recovered.
|
|
</p>
|
|
<label class="field-label" for="bootstrap-command">
|
|
Install command
|
|
</label>
|
|
<div class="bootstrap-command-row">
|
|
<input
|
|
id="bootstrap-command"
|
|
class="form-control mono"
|
|
type="text"
|
|
readonly
|
|
:value="bootstrapcommand"
|
|
/>
|
|
<button
|
|
class="btn btn-secondary"
|
|
type="button"
|
|
@click="copyBootstrapCommand"
|
|
>
|
|
{{ bootstrapcopied ? 'Copied' : 'Copy' }}
|
|
</button>
|
|
</div>
|
|
<p v-if="bootstrapscripturl" class="field-hint">
|
|
Or save the script and run it on the PC instead:
|
|
<a
|
|
class="bootstrap-download"
|
|
:href="bootstrapscripturl"
|
|
download="shopdb-bootstrap.ps1"
|
|
>
|
|
Download .ps1
|
|
</a>
|
|
(the same token; it carries the secret, so treat the file the
|
|
way you treat the command).
|
|
</p>
|
|
<p v-if="bootstraptoken" class="field-hint">
|
|
Expires {{ formatDate(bootstraptoken.expiresat) }}.
|
|
{{ bootstraptoken.remaining }} of
|
|
{{ bootstraptoken.maxuses }} uses remaining.
|
|
</p>
|
|
</template>
|
|
|
|
<p class="field-hint">
|
|
The PC installs with no shared key and then waits for approval in
|
|
Settings > Endpoints > Enrollment.
|
|
</p>
|
|
|
|
<!-- The deprecated query form of the bootstrap token. Rendered here
|
|
because this is the page that mints the command the setting
|
|
governs: turning it off invalidates every command already handed
|
|
out that carries ?t=. -->
|
|
<label class="checkbox-row">
|
|
<input
|
|
type="checkbox"
|
|
class="toggle-querytoken"
|
|
:checked="querytokenallowed"
|
|
:disabled="querytokenbusy"
|
|
@change="onQueryTokenToggle($event)"
|
|
/>
|
|
<span>
|
|
<strong>Accept the token in the URL.</strong> On by default:
|
|
the pasted one-line command and the Download .ps1 link above both
|
|
send the token as <code>?t=</code>, which is the only form a
|
|
browser download link can use. The token lands in the IIS and
|
|
proxy logs that way, so the header form
|
|
(<code>X-Endpoint-Bootstrap-Token</code>) replaces it. Turn this off
|
|
and every install command still in a technician's hands stops
|
|
working immediately - re-issue them first.
|
|
</span>
|
|
</label>
|
|
<p v-if="querytokenerror" class="field-error">{{ querytokenerror }}</p>
|
|
|
|
<table v-if="bootstraptokens.length" class="versions-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Token</th>
|
|
<th>State</th>
|
|
<th>Expires</th>
|
|
<th>Uses</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="entry in bootstraptokens" :key="entry.tokenid">
|
|
<td class="mono">{{ entry.tokenprefix }}</td>
|
|
<td>{{ entry.state }}</td>
|
|
<td>{{ formatDate(entry.expiresat) }}</td>
|
|
<td>{{ entry.usecount }} / {{ entry.maxuses }}</td>
|
|
<td>
|
|
<button
|
|
v-if="entry.state === 'active'"
|
|
class="btn-link"
|
|
type="button"
|
|
:disabled="bootstrapbusy"
|
|
@click="revokeBootstrapToken(entry.tokenid)"
|
|
>
|
|
Revoke
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p v-else class="field-hint">No install tokens yet.</p>
|
|
|
|
<p v-if="bootstrapmessage" class="field-hint">
|
|
{{ bootstrapmessage }}
|
|
</p>
|
|
<p v-if="bootstraperror" class="field-error">{{ bootstraperror }}</p>
|
|
</div>
|
|
|
|
<!-- ================================================================
|
|
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, computed, 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('')
|
|
// Enrollment progress, from the same counts the Enrollment page shows. Null
|
|
// when the read fails; the key controls work without it.
|
|
const enrollment = ref(null)
|
|
|
|
// 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 agentwarning = ref('')
|
|
const agentincludesharedkey = ref(true)
|
|
const agentnextserverpin = ref('')
|
|
const agentserverpin = ref('')
|
|
|
|
// One-line install tokens. bootstrapcommand holds the secret ONLY for the
|
|
// response that minted it: the list read never carries one, so a reload leaves
|
|
// the field empty rather than re-showing a token nobody can recover.
|
|
const bootstraphours = ref(24)
|
|
const bootstrapmaxuses = ref(25)
|
|
const bootstrapbusy = ref(false)
|
|
const bootstrapcommand = ref('')
|
|
const bootstrapscripturl = ref('')
|
|
const bootstraptoken = ref(null)
|
|
const bootstrapcopied = ref(false)
|
|
const bootstraptokens = ref([])
|
|
const bootstrapmessage = ref('')
|
|
const bootstraperror = ref('')
|
|
|
|
// plugin.endpoints.bootstrapquerytoken, read and written through the core
|
|
// settings API: the endpoints plugin has no settings route of its own for it,
|
|
// and the key is declared in get_settings_defaults so the upsert on PUT gets
|
|
// the boolean valuetype and the description from that declaration.
|
|
const QUERY_TOKEN_SETTING = 'plugin.endpoints.bootstrapquerytoken'
|
|
const querytokenallowed = ref(true)
|
|
const querytokenbusy = ref(false)
|
|
const querytokenerror = 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
|
|
}
|
|
|
|
const enrolledPercent = computed(() => {
|
|
const total = enrollment.value?.total || 0
|
|
return total ? Math.round((enrollment.value.enrolled / total) * 100) : 0
|
|
})
|
|
|
|
async function loadEnrollment() {
|
|
try {
|
|
const response = await api.get('/endpoints/enrollment', { params: { perpage: 1 } })
|
|
const data = response.data.data || {}
|
|
const counts = data.counts || {}
|
|
const enrolled = counts.enrolled || 0
|
|
// pending PCs asked but hold no credential yet: still on the shared key.
|
|
const notenrolled = (counts.notenrolled || 0) + (counts.pendingcomputers || 0)
|
|
enrollment.value = {
|
|
enrolled,
|
|
notenrolled,
|
|
total: enrolled + notenrolled,
|
|
requests: (data.requests || []).filter(
|
|
(entry) => entry.status === 'pending' && entry.reason !== 'reenroll blocked').length,
|
|
sharedkeyallowed: data.settings?.sharedkeyallowed !== false
|
|
}
|
|
} catch (loadError) {
|
|
enrollment.value = null
|
|
console.error(loadError)
|
|
}
|
|
}
|
|
|
|
// The number of PCs a change to the shared key reaches, for the confirms.
|
|
function notEnrolledPhrase() {
|
|
if (!enrollment.value) return 'Every PC that has not enrolled'
|
|
const count = enrollment.value.notenrolled
|
|
return `${count} PC${count === 1 ? '' : 's'} not enrolled`
|
|
}
|
|
|
|
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
|
|
}
|
|
loadEnrollment()
|
|
loadBootstrapTokens()
|
|
loadQueryTokenSetting()
|
|
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)
|
|
}
|
|
})
|
|
|
|
async function loadQueryTokenSetting() {
|
|
try {
|
|
const response = await api.get(`/settings/${QUERY_TOKEN_SETTING}`)
|
|
// The backend reads it as bool(Setting.get(key, True)), so only an
|
|
// explicit false is off. A row seeded as boolean comes back as a real
|
|
// false; an older row that was created as a string comes back as 'false'.
|
|
const value = response.data.data?.value
|
|
querytokenallowed.value = !(value === false || value === 'false')
|
|
} catch (loadError) {
|
|
// 404 = the row was never seeded (plugin installed before the key was
|
|
// declared). The backend default is on, so show on.
|
|
if (loadError.response?.status !== 404) console.error(loadError)
|
|
querytokenallowed.value = true
|
|
}
|
|
}
|
|
|
|
async function onQueryTokenToggle(event) {
|
|
const turningOn = event.target.checked
|
|
if (!turningOn) {
|
|
const answer = prompt(
|
|
'Stop accepting the token in the URL?\n\n'
|
|
+ 'Every install command already handed out uses ?t= and stops '
|
|
+ 'working at once, including the Download .ps1 links. Re-issue them '
|
|
+ 'before turning this off.\n\n'
|
|
+ 'Type OFF to confirm.')
|
|
if (!answer || answer.trim().toUpperCase() !== 'OFF') {
|
|
event.target.checked = true
|
|
return
|
|
}
|
|
}
|
|
querytokenbusy.value = true
|
|
querytokenerror.value = ''
|
|
try {
|
|
// PUT upserts, so this works on a site whose row was never seeded.
|
|
await api.put(`/settings/${QUERY_TOKEN_SETTING}`, { value: turningOn })
|
|
querytokenallowed.value = turningOn
|
|
} catch (saveError) {
|
|
querytokenerror.value = saveError.response?.status === 403
|
|
? 'Only an administrator can change this setting'
|
|
: 'Could not save the setting'
|
|
event.target.checked = querytokenallowed.value
|
|
console.error(saveError)
|
|
} finally {
|
|
querytokenbusy.value = false
|
|
}
|
|
}
|
|
|
|
async function loadBootstrapTokens() {
|
|
try {
|
|
const response = await api.get('/endpoints/siteagent/bootstrap/tokens')
|
|
bootstraptokens.value = response.data.data?.tokens || []
|
|
} catch (loadError) {
|
|
bootstraptokens.value = []
|
|
console.error(loadError)
|
|
}
|
|
}
|
|
|
|
async function generateBootstrapToken() {
|
|
bootstrapbusy.value = true
|
|
bootstrapmessage.value = ''
|
|
bootstraperror.value = ''
|
|
bootstrapcommand.value = ''
|
|
bootstrapscripturl.value = ''
|
|
bootstraptoken.value = null
|
|
bootstrapcopied.value = false
|
|
try {
|
|
const response = await api.post('/endpoints/siteagent/bootstrap/tokens', {
|
|
hours: bootstraphours.value,
|
|
maxuses: bootstrapmaxuses.value
|
|
})
|
|
const data = response.data.data || {}
|
|
bootstrapcommand.value = data.command || ''
|
|
bootstrapscripturl.value = data.scripturl || ''
|
|
bootstraptoken.value = data.token || null
|
|
bootstrapmessage.value = 'Install command generated.'
|
|
await loadBootstrapTokens()
|
|
} catch (generateError) {
|
|
bootstraperror.value = generateError.response?.data?.data?.error?.message
|
|
|| 'Could not generate an install token'
|
|
console.error(generateError)
|
|
} finally {
|
|
bootstrapbusy.value = false
|
|
}
|
|
}
|
|
|
|
async function copyBootstrapCommand() {
|
|
try {
|
|
await navigator.clipboard.writeText(bootstrapcommand.value)
|
|
bootstrapcopied.value = true
|
|
} catch (copyError) {
|
|
console.error(copyError)
|
|
}
|
|
}
|
|
|
|
async function revokeBootstrapToken(tokenid) {
|
|
bootstrapbusy.value = true
|
|
bootstrapmessage.value = ''
|
|
bootstraperror.value = ''
|
|
try {
|
|
await api.post(`/endpoints/siteagent/bootstrap/tokens/${tokenid}/revoke`)
|
|
bootstrapmessage.value = 'Install token revoked.'
|
|
await loadBootstrapTokens()
|
|
} catch (revokeError) {
|
|
bootstraperror.value = revokeError.response?.data?.data?.error?.message
|
|
|| 'Could not revoke the install token'
|
|
console.error(revokeError)
|
|
} finally {
|
|
bootstrapbusy.value = false
|
|
}
|
|
}
|
|
|
|
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 shared key? The current key stops working '
|
|
+ `immediately. ${notEnrolledPhrase()} will need the new key before they `
|
|
+ 'can report or enroll. Enrolled PCs are not affected.')) {
|
|
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 shared key? '
|
|
+ `${notEnrolledPhrase()} will lose access: they stop reporting and cannot `
|
|
+ 'enroll until a new key is generated. Enrolled PCs keep working.')) {
|
|
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 = ''
|
|
agentwarning.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.'
|
|
agentwarning.value = response.headers?.['x-shopdb-build-warning'] || ''
|
|
} 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; }
|
|
.enrollment-counts {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem 1.5rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.enrollment-count { font-size: 0.9rem; }
|
|
.progress-track {
|
|
height: 6px;
|
|
border-radius: 3px;
|
|
background: var(--color-border, #333);
|
|
overflow: hidden;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.progress-fill {
|
|
height: 100%;
|
|
background: var(--color-success, #4caf50);
|
|
}
|
|
.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);
|
|
}
|
|
.bootstrap-block {
|
|
margin-top: 1rem;
|
|
padding: 1rem;
|
|
}
|
|
.bootstrap-inputs {
|
|
display: flex;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.bootstrap-inputs .form-control { max-width: 9rem; }
|
|
.bootstrap-command-row {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
}
|
|
.bootstrap-command-row .form-control { flex: 1; }
|
|
.current-tag {
|
|
font-size: 0.8rem;
|
|
color: var(--color-success, #4caf50);
|
|
font-weight: 600;
|
|
}
|
|
</style>
|