Merge the wilm delivery work into main
Ben Baker's installed-software collection, software delivery (run_script and
update_app), agentless adoption tool and command-queue hardening, with his
authorship preserved.
Reviewed before promotion. Fixed: the endpoints plugin tests were never collected
by pytest, the SBOM snapshot table's key would exhaust on MySQL, ingest did not
truncate two columns strict MySQL would reject, and update_app's version branch
pinned a digest for a file that route never serves. Agent packaging moved off
require_role('admin') onto a new endpoints.agentpackage permission.
Contract 0.27.0. Full suite 2231, frontend 314.
This commit is contained in:
@@ -17,6 +17,16 @@ export default [
|
||||
name: 'endpoints-agentkey-settings',
|
||||
component: () => import('./views/AgentKeySettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
|
||||
},
|
||||
// TEMPORARY / DEV ONLY. A manual bench to exercise the update_app and
|
||||
// run_script delivery path end to end while Cameron builds the real UI.
|
||||
// Gated on endpoints.deploy inside the view; remove once the production
|
||||
// delivery UI lands.
|
||||
{
|
||||
path: 'endpoints/delivery-test',
|
||||
name: 'endpoints-delivery-test',
|
||||
component: () => import('./views/DeliveryTestBench.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'endpoints' }
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -14,6 +14,16 @@
|
||||
================================================================= -->
|
||||
<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
|
||||
@@ -75,8 +85,19 @@
|
||||
<div v-if="secret" class="card form-card secret-card">
|
||||
<h4>Save this key now</h4>
|
||||
<p class="field-hint">
|
||||
It will not be shown again. Configure it as the agent
|
||||
<code>X-API-Key</code> header value.
|
||||
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">
|
||||
@@ -138,6 +159,57 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ====================================================================
|
||||
Adoption tool (spike)
|
||||
================================================================= -->
|
||||
<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 (placeholder)
|
||||
================================================================= -->
|
||||
@@ -145,18 +217,92 @@
|
||||
<h3>Build site agent</h3>
|
||||
<div class="card form-card">
|
||||
<p class="field-hint">
|
||||
Produce an instance-locked agent executable stamped with this server's
|
||||
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.
|
||||
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 you must drop
|
||||
<code>shopdbagent.exe</code> beside them before deploying. Stage a
|
||||
prebuilt agent under <code>instance/siteagent/</code> to have it ship
|
||||
in the 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">
|
||||
<strong>Coming soon.</strong> The build pipeline is not wired up yet -
|
||||
the C# agent runtime is still under development. Generate a key above
|
||||
and configure it manually as the agent <code>X-API-Key</code> for now.
|
||||
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>
|
||||
<button class="btn btn-secondary" type="button" disabled>
|
||||
Build agent (coming soon)
|
||||
</button>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -197,6 +343,26 @@ const copied = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
|
||||
// Adoption tool (spike): 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('')
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return ''
|
||||
const parsed = new Date(value)
|
||||
@@ -209,6 +375,8 @@ function applyStatus(data) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
adoptbaseurl.value = window.location.origin
|
||||
agentbaseurl.value = window.location.origin
|
||||
try {
|
||||
const response = await api.get('/endpoints/agentkey')
|
||||
applyStatus(response.data.data || {})
|
||||
@@ -220,6 +388,15 @@ onMounted(async () => {
|
||||
} 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
|
||||
} catch (statusError) {
|
||||
console.error(statusError)
|
||||
}
|
||||
})
|
||||
|
||||
async function regenerate() {
|
||||
@@ -238,6 +415,10 @@ async function 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
|
||||
@@ -276,6 +457,89 @@ async function copySecret() {
|
||||
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
|
||||
link.download = '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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -284,7 +548,27 @@ async function copySecret() {
|
||||
.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; }
|
||||
|
||||
327
plugins/endpoints/frontend/views/DeliveryTestBench.vue
Normal file
327
plugins/endpoints/frontend/views/DeliveryTestBench.vue
Normal file
@@ -0,0 +1,327 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Delivery Test Bench</h2>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
TEMPORARY / DEV ONLY. This view exists to exercise the update_app and
|
||||
run_script delivery path end to end and to take notes for the real UI
|
||||
(owned by Cameron). It is gated on endpoints.deploy. Remove it once the
|
||||
production delivery UI lands. Nothing here is a supported operator surface.
|
||||
-->
|
||||
<div class="card dev-note">
|
||||
<strong>Development test vehicle.</strong>
|
||||
This bench pushes an allowlisted delivery command to one endpoint and reads
|
||||
back the result. The agent only runs a script name it already holds in its
|
||||
local allowlist, and only installs a ShopDB-hosted package pinned by digest.
|
||||
Both actions require the <code>endpoints.deploy</code> permission.
|
||||
</div>
|
||||
|
||||
<div v-if="!candeploy" class="card load-error">
|
||||
Your account lacks the <code>endpoints.deploy</code> permission, so no
|
||||
delivery command can be queued. Ask an administrator to grant it.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 1. Pick the target endpoint -->
|
||||
<div class="card bench-section">
|
||||
<h3>1. Target endpoint</h3>
|
||||
<div class="bench-row">
|
||||
<input
|
||||
v-model="endpointSearch"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search hostname or asset..."
|
||||
@keyup.enter="loadEndpoints"
|
||||
/>
|
||||
<button class="btn btn-secondary btn-sm" :disabled="busy" @click="loadEndpoints">Search</button>
|
||||
</div>
|
||||
<select v-model="selectedAssetId" class="form-control" :disabled="busy">
|
||||
<option value="">Select an endpoint...</option>
|
||||
<option v-for="row in endpoints" :key="row.assetid" :value="row.assetid">
|
||||
{{ row.hostname }} (asset {{ row.assetid }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 2. Run a ShopDB-hosted script from the library -->
|
||||
<div class="card bench-section">
|
||||
<h3>2. Run script</h3>
|
||||
<p class="bench-hint">
|
||||
Pick a script from the ShopDB script library. The agent fetches the
|
||||
script body by digest with its collector credential and runs it in the
|
||||
script's configured run context (system or the interactive user).
|
||||
</p>
|
||||
<div class="bench-row">
|
||||
<select v-model="selectedScriptId" class="form-control" :disabled="busy">
|
||||
<option value="">Select script...</option>
|
||||
<option v-for="script in scripts" :key="script.id" :value="script.id">
|
||||
{{ script.scriptname }} ({{ script.runcontext }})
|
||||
<template v-if="!script.sha256"> (no body - upload first)</template>
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
class="btn btn-warning btn-sm"
|
||||
:disabled="busy || !selectedAssetId || !selectedScriptId"
|
||||
@click="pushScript"
|
||||
>
|
||||
Push run_script
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Install/update an application -->
|
||||
<div class="card bench-section">
|
||||
<h3>3. Install/update application</h3>
|
||||
<p class="bench-hint">
|
||||
Pick an application, then the app-level install source (the installer
|
||||
uploaded on the application itself). The agent fetches the pinned
|
||||
package by digest with its collector credential and runs it silently.
|
||||
</p>
|
||||
<div class="bench-row">
|
||||
<select v-model="selectedAppId" class="form-control" :disabled="busy" @change="loadSources">
|
||||
<option value="">Select application...</option>
|
||||
<option v-for="app in applications" :key="app.appid" :value="app.appid">
|
||||
{{ app.appname }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="selectedSource" class="form-control" :disabled="busy || !selectedAppId">
|
||||
<option value="">Select install source...</option>
|
||||
<option v-for="source in installSources" :key="source.value" :value="source.value">
|
||||
{{ source.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
class="btn btn-warning btn-sm"
|
||||
:disabled="busy || !selectedAssetId || !selectedSource"
|
||||
@click="pushUpdateApp"
|
||||
>
|
||||
Push update_app
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Command results (the Web UI log pane) -->
|
||||
<div class="card bench-section">
|
||||
<div class="bench-row bench-row-between">
|
||||
<h3>4. Command log (Web UI side)</h3>
|
||||
<button class="btn btn-secondary btn-sm" :disabled="busy || !selectedAssetId" @click="loadCommands">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!selectedAssetId" class="bench-hint">Select an endpoint to see its recent commands.</p>
|
||||
<table v-else-if="commands.length" class="table bench-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Action</th>
|
||||
<th>Target</th>
|
||||
<th>Status</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="command in commands" :key="command.commandid">
|
||||
<td>{{ formatWhen(command.createdat) }}</td>
|
||||
<td>{{ command.action }}</td>
|
||||
<td>{{ command.target || '-' }}</td>
|
||||
<td><span class="status-badge" :class="statusClass(command.status)">{{ command.status }}</span></td>
|
||||
<td class="result-cell">{{ command.resultmessage || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="bench-hint">No commands recorded for this endpoint yet.</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { endpointsApi, applicationsApi, scriptsApi } from '@/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
// Gated in the UI for convenience only; every enqueue is re-checked server-side.
|
||||
const candeploy = computed(() => authStore.hasPermission('endpoints.deploy'))
|
||||
|
||||
const busy = ref(false)
|
||||
|
||||
const endpointSearch = ref('')
|
||||
const endpoints = ref([])
|
||||
const selectedAssetId = ref('')
|
||||
|
||||
const scripts = ref([])
|
||||
const selectedScriptId = ref('')
|
||||
|
||||
const applications = ref([])
|
||||
const selectedAppId = ref('')
|
||||
const installSources = ref([])
|
||||
const selectedSource = ref('')
|
||||
|
||||
const commands = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
if (!candeploy.value) return
|
||||
loadEndpoints()
|
||||
loadApplications()
|
||||
loadScripts()
|
||||
})
|
||||
|
||||
async function loadEndpoints() {
|
||||
busy.value = true
|
||||
try {
|
||||
const params = { perpage: 100 }
|
||||
if (endpointSearch.value) params.search = endpointSearch.value
|
||||
const response = await endpointsApi.list(params)
|
||||
endpoints.value = response.data.data || []
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Could not load endpoints'))
|
||||
endpoints.value = []
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApplications() {
|
||||
try {
|
||||
applications.value = await applicationsApi.listAll()
|
||||
} catch (error) {
|
||||
applications.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScripts() {
|
||||
try {
|
||||
scripts.value = await scriptsApi.listAll()
|
||||
} catch (error) {
|
||||
scripts.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// Build the install-source options for the chosen application: the app-level
|
||||
// source (the installer uploaded on the application itself). The wire target is
|
||||
// 'app:<id>', which the server resolves into a pinned delivery descriptor.
|
||||
async function loadSources() {
|
||||
selectedSource.value = ''
|
||||
installSources.value = []
|
||||
if (!selectedAppId.value) return
|
||||
|
||||
const sources = []
|
||||
const app = applications.value.find(row => String(row.appid) === String(selectedAppId.value))
|
||||
if (app && app.installpath) {
|
||||
sources.push({
|
||||
value: `app:${app.appid}`,
|
||||
label: app.installersha256
|
||||
? 'App-level installer'
|
||||
: 'App-level installer (no digest - upload package first)',
|
||||
})
|
||||
}
|
||||
|
||||
installSources.value = sources
|
||||
}
|
||||
|
||||
async function pushScript() {
|
||||
await enqueue('run_script', `script:${selectedScriptId.value}`, 'run_script')
|
||||
}
|
||||
|
||||
async function pushUpdateApp() {
|
||||
await enqueue('update_app', selectedSource.value, 'update_app')
|
||||
}
|
||||
|
||||
async function enqueue(action, target, label) {
|
||||
if (!selectedAssetId.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
await endpointsApi.enqueueCommand(selectedAssetId.value, action, target)
|
||||
toast.success(`${label} queued`)
|
||||
await loadCommands()
|
||||
} catch (error) {
|
||||
// 409 = a command is already in flight for this endpoint (at-most-one queue).
|
||||
const status = error.response?.status
|
||||
if (status === 409) {
|
||||
toast.info('A command is already in flight for this endpoint')
|
||||
} else {
|
||||
toast.error(apiError(error, `${label} failed`))
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCommands() {
|
||||
if (!selectedAssetId.value) return
|
||||
try {
|
||||
const response = await endpointsApi.listCommands(selectedAssetId.value)
|
||||
commands.value = response.data.data || []
|
||||
} catch (error) {
|
||||
commands.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function formatWhen(iso) {
|
||||
if (!iso) return '-'
|
||||
const date = new Date(iso)
|
||||
return Number.isNaN(date.getTime()) ? iso : date.toLocaleString()
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
if (status === 'succeeded') return 'ok'
|
||||
if (status === 'failed') return 'bad'
|
||||
if (status === 'expired') return 'bad'
|
||||
if (status === 'running') return 'warn'
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dev-note {
|
||||
border-left: 3px solid var(--warning, #d6a012);
|
||||
}
|
||||
.bench-section h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.bench-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.bench-row-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.bench-hint {
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.bench-table {
|
||||
width: 100%;
|
||||
}
|
||||
.result-cell {
|
||||
max-width: 32rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.status-badge {
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.status-badge.ok {
|
||||
color: var(--success, #2e7d32);
|
||||
}
|
||||
.status-badge.bad {
|
||||
color: var(--danger, #c62828);
|
||||
}
|
||||
.status-badge.warn {
|
||||
color: var(--warning, #d6a012);
|
||||
}
|
||||
.load-error {
|
||||
color: var(--danger, #c62828);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user