Adds the two delivery actions to the endpoints command allowlist, each gated on a new endpoints.deploy permission ON TOP OF endpoints.command so pushing software is a heavier grant than a routine toggle. Enqueue validates update_app appversionid ownership and installer presence before queueing. New collector-authenticated download routes let the agent fetch installer packages and script bodies by digest rather than pre-staging them or sending bodies over the command wire. Wires the scriptsApi client, the siteagent generator routes, and a temporary dev-only Delivery test bench (view/route/nav) for exercising the path while the production UI is built.
328 lines
10 KiB
Vue
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>
|