Files
shopdb-flask/plugins/endpoints/frontend/views/DeliveryTestBench.vue
Cam Proudlock 6680c3c5de 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.
2026-08-30 19:41:15 -04:00

328 lines
10 KiB
Vue

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