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:
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