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.
444 lines
15 KiB
Vue
444 lines
15 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>System Scripts</h2>
|
|
<button class="btn btn-primary" @click="openModal()">+ Add Script</button>
|
|
</div>
|
|
|
|
<p class="setting-description">
|
|
PowerShell the site agent can be told to run on an endpoint. A script is
|
|
stored here once, pinned by digest, and fetched by the agent at push time
|
|
so the bytes that run are the bytes that were reviewed.
|
|
</p>
|
|
|
|
<div class="warning-banner">
|
|
<strong>Every script here runs as SYSTEM.</strong>
|
|
There is no user context and no prompt: the agent is a service, so a
|
|
script cannot see a signed-in person's desktop, mapped drives, or
|
|
credentials. Scripts are intended to TRIGGER an already registered
|
|
scheduled task on the endpoint, which is what does the real work. Anything
|
|
written to act "as the logged-on user" will not behave the way it reads.
|
|
</div>
|
|
|
|
<details class="explainer">
|
|
<summary>How does this work?</summary>
|
|
<div class="explainer-body">
|
|
<h4>Delivery</h4>
|
|
<p>
|
|
The body is stored here and hashed. At push time the agent downloads it
|
|
with its own credential and checks the digest before running anything,
|
|
so the bytes that run are the bytes that were reviewed. The body never
|
|
travels on the command channel and is deleted from the endpoint after
|
|
the run.
|
|
</p>
|
|
|
|
<h4>Runs in</h4>
|
|
<p>
|
|
<strong>System</strong> is the agent's own service account: no desktop,
|
|
no mapped drives, no signed-in person's credentials.
|
|
<strong>User</strong> runs in the interactive session instead, and only
|
|
works when somebody is actually signed in.
|
|
</p>
|
|
|
|
<h4>Run in folder</h4>
|
|
<p>
|
|
The working directory for the run. Blank means the agent's staging
|
|
folder, which is right for a self-contained script. Set it when the
|
|
script calls a tool installed separately on the endpoint by relative
|
|
path. If the folder is missing the run fails outright rather than
|
|
running somewhere unintended.
|
|
</p>
|
|
|
|
<h4>When a line fails</h4>
|
|
<p>
|
|
This is the one worth understanding. PowerShell treats most errors as
|
|
<em>non-terminating</em>: it writes the error, carries on to the next
|
|
line, and still exits zero. Because the command's verdict comes from
|
|
that exit code, <strong>Continue</strong> means a script can report
|
|
success having accomplished nothing.
|
|
</p>
|
|
<p>
|
|
<strong>Stop</strong> makes the first error end the run, so the verdict
|
|
means what you would assume it means. It is not the default because it
|
|
changes how a script behaves: a body that tolerates a failing cleanup,
|
|
probes with a cmdlet that errors when something is absent, or loops
|
|
past bad items will start aborting partway through, leaving the
|
|
endpoint half-configured. Read the script first, then switch it on.
|
|
</p>
|
|
|
|
<h4>Where the output goes</h4>
|
|
<p>
|
|
Script output is always written to the agent log on the endpoint. It is
|
|
sent back to ShopDB only when the run fails, so routine output stays on
|
|
the machine that produced it. Long output is truncated and marked as
|
|
such.
|
|
</p>
|
|
<p class="hint">
|
|
In Stop mode the agent adds one line to the top of the script, so an
|
|
error reported on line N refers to line N-1 of the body shown here.
|
|
</p>
|
|
</div>
|
|
</details>
|
|
|
|
<div class="card">
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
<template v-else>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Description</th>
|
|
<th>Runs in</th>
|
|
<th>On error</th>
|
|
<th>Body</th>
|
|
<th>Digest</th>
|
|
<th>Active</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="s in scripts" :key="s.scriptid">
|
|
<td>{{ s.scriptname }}</td>
|
|
<td class="cell-truncate" :title="s.scriptdescription">
|
|
{{ s.scriptdescription || '-' }}
|
|
</td>
|
|
<td class="cell-truncate mono" :title="s.workingdirectory">
|
|
{{ s.workingdirectory || 'Agent staging folder' }}
|
|
</td>
|
|
<td>
|
|
<span v-if="s.errormode === 'strict'" class="badge badge-secondary">
|
|
Stop on first error
|
|
</span>
|
|
<span v-else class="badge badge-warning" title="A failing line does not fail the command">
|
|
Continue
|
|
</span>
|
|
<span
|
|
v-if="s.captureoutput"
|
|
class="badge badge-secondary"
|
|
title="A successful run returns its output to ShopDB"
|
|
>
|
|
Returns output
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<span v-if="s.hasbody" class="badge badge-secondary">{{ s.filename }}</span>
|
|
<span v-else class="badge badge-warning">No body - cannot run</span>
|
|
</td>
|
|
<td class="cell-truncate mono" :title="s.sha256">{{ shortdigest(s) }}</td>
|
|
<td>{{ s.isactive ? 'Yes' : 'No' }}</td>
|
|
<td class="actions">
|
|
<button class="btn btn-secondary btn-sm" @click="openModal(s)">Edit</button>
|
|
<button class="btn btn-danger btn-sm" @click="remove(s)">Delete</button>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="scripts.length === 0">
|
|
<td colspan="8" style="text-align:center; color: var(--text-light);">
|
|
No scripts
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-if="showModal" class="modal-overlay">
|
|
<div class="modal modal-lg">
|
|
<div class="modal-header">
|
|
<h3>{{ editing ? 'Edit Script' : 'Add Script' }}</h3>
|
|
</div>
|
|
<form @submit.prevent="save">
|
|
<div class="modal-body">
|
|
<div class="warning-banner">
|
|
<strong>This script will run as SYSTEM on every endpoint it is
|
|
pushed to.</strong>
|
|
Write it to trigger a scheduled task that already exists on the
|
|
endpoint. It has no user session, so anything depending on a
|
|
person being signed in will not work.
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Name *</label>
|
|
<input v-model="form.scriptname" type="text" class="form-control" required />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Description <span class="hint">(what it triggers, and why)</span></label>
|
|
<textarea v-model="form.scriptdescription" class="form-control" rows="3"></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
Script file <span class="hint">(.ps1, replaces the stored body)</span>
|
|
</label>
|
|
<input type="file" accept=".ps1" class="form-control" @change="pickfile" />
|
|
<p v-if="editing && editing.hasbody" class="hint">
|
|
Current: {{ editing.filename }}. Uploading replaces it and
|
|
restamps the digest.
|
|
</p>
|
|
<p v-else-if="editing" class="hint">
|
|
No body uploaded yet. A script without a body cannot be pushed.
|
|
</p>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
Run in folder
|
|
<span class="hint">(optional, endpoint-local absolute path)</span>
|
|
</label>
|
|
<input
|
|
v-model="form.workingdirectory"
|
|
type="text"
|
|
class="form-control mono"
|
|
placeholder="C:\Program Files\CorporateTools"
|
|
/>
|
|
<details class="field-help">
|
|
<summary>How this works</summary>
|
|
<p>
|
|
Leave blank to run from the agent's staging folder. Set this only
|
|
when the script calls a tool already installed on the endpoint by
|
|
relative path. The folder must exist on the endpoint or the push
|
|
fails rather than running somewhere else.
|
|
</p>
|
|
</details>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
When a line fails
|
|
<span class="hint">(how the run is judged)</span>
|
|
</label>
|
|
<select v-model="form.errormode" class="form-control">
|
|
<option value="default">Continue - report success if the script reaches the end</option>
|
|
<option value="strict">Stop on the first error and fail the command</option>
|
|
</select>
|
|
<details class="field-help">
|
|
<summary>How this works</summary>
|
|
<p>
|
|
PowerShell normally writes an error and carries on, and still
|
|
exits zero, so a script can report success having done none of
|
|
its work. Stop makes the first failure end the run and fail the
|
|
command. Leave this on Continue unless you have read the script
|
|
and know it does not rely on a step being allowed to fail.
|
|
</p>
|
|
</details>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
<input v-model="form.captureoutput" type="checkbox" />
|
|
Return output to ShopDB on success
|
|
</label>
|
|
<details class="field-help">
|
|
<summary>How this works</summary>
|
|
<p>
|
|
A failed run always sends its output back, so you can diagnose
|
|
it from the Run Scripts history without going to the machine.
|
|
A successful run does not, unless you tick this.
|
|
</p>
|
|
<p>
|
|
Leave it off for scripts whose output could contain anything
|
|
sensitive - account names, share paths, anything a Write-Host
|
|
left behind. Turn it on for scripts where seeing the output is
|
|
the only way to tell the run did real work. Either way the
|
|
full output is written to the agent log on the endpoint.
|
|
</p>
|
|
</details>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
<input v-model="form.isactive" type="checkbox" />
|
|
Active
|
|
</label>
|
|
</div>
|
|
|
|
<div v-if="error" class="error-message">{{ error }}</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
|
<button type="submit" class="btn btn-primary" :disabled="saving">
|
|
{{ saving ? 'Saving...' : 'Save' }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { scriptsApi } from '@/api'
|
|
import { useToast } from '@/composables/toast'
|
|
import { apiError } from '@/utils/apiError'
|
|
|
|
const toast = useToast()
|
|
|
|
const scripts = ref([])
|
|
const loading = ref(true)
|
|
|
|
const showModal = ref(false)
|
|
const editing = ref(null)
|
|
const saving = ref(false)
|
|
const error = ref('')
|
|
const pendingfile = ref(null)
|
|
|
|
const form = ref(emptyForm())
|
|
|
|
function emptyForm() {
|
|
return {
|
|
scriptname: '',
|
|
scriptdescription: '',
|
|
workingdirectory: '',
|
|
errormode: 'default',
|
|
captureoutput: false,
|
|
isactive: true
|
|
}
|
|
}
|
|
|
|
function shortdigest(s) {
|
|
return s.sha256 ? s.sha256.slice(0, 12) : '-'
|
|
}
|
|
|
|
async function loadData() {
|
|
loading.value = true
|
|
try {
|
|
const response = await scriptsApi.list({ active: 'false' })
|
|
scripts.value = response.data.data || []
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Failed to load scripts'))
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function pickfile(event) {
|
|
pendingfile.value = event.target.files[0] || null
|
|
}
|
|
|
|
function openModal(script = null) {
|
|
error.value = ''
|
|
pendingfile.value = null
|
|
editing.value = script
|
|
form.value = script
|
|
? {
|
|
scriptname: script.scriptname,
|
|
scriptdescription: script.scriptdescription || '',
|
|
workingdirectory: script.workingdirectory || '',
|
|
errormode: script.errormode || 'default',
|
|
captureoutput: !!script.captureoutput,
|
|
isactive: script.isactive
|
|
}
|
|
: emptyForm()
|
|
showModal.value = true
|
|
}
|
|
|
|
function closeModal() {
|
|
showModal.value = false
|
|
editing.value = null
|
|
pendingfile.value = null
|
|
}
|
|
|
|
async function save() {
|
|
saving.value = true
|
|
error.value = ''
|
|
try {
|
|
const payload = {
|
|
scriptname: form.value.scriptname,
|
|
scriptdescription: form.value.scriptdescription,
|
|
workingdirectory: form.value.workingdirectory,
|
|
errormode: form.value.errormode,
|
|
captureoutput: form.value.captureoutput,
|
|
isactive: form.value.isactive
|
|
}
|
|
let scriptid
|
|
if (editing.value) {
|
|
await scriptsApi.update(editing.value.scriptid, payload)
|
|
scriptid = editing.value.scriptid
|
|
} else {
|
|
const created = await scriptsApi.create(payload)
|
|
scriptid = created.data.data.scriptid
|
|
}
|
|
// Second call on purpose: the body is multipart and the catalog row is
|
|
// JSON. A failure here leaves a bodyless row, which the list marks as
|
|
// unrunnable rather than pretending it is ready to push.
|
|
if (pendingfile.value) {
|
|
await scriptsApi.uploadBody(scriptid, pendingfile.value)
|
|
}
|
|
closeModal()
|
|
loadData()
|
|
} catch (err) {
|
|
error.value = apiError(err, 'Failed to save script')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function remove(s) {
|
|
if (!confirm(`Delete script "${s.scriptname}"?`)) return
|
|
try {
|
|
await scriptsApi.delete(s.scriptid)
|
|
loadData()
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Failed to delete'))
|
|
}
|
|
}
|
|
|
|
onMounted(loadData)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.cell-truncate {
|
|
max-width: 240px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.mono { font-family: monospace; }
|
|
.hint { color: var(--text-light); font-weight: normal; }
|
|
.warning-banner {
|
|
border: 1px solid var(--warning);
|
|
border-left-width: 4px;
|
|
border-radius: 4px;
|
|
padding: 0.75rem 1rem;
|
|
margin-bottom: 1rem;
|
|
background: rgba(184, 134, 11, 0.08);
|
|
}
|
|
.explainer {
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
margin-bottom: 1rem;
|
|
background: var(--bg-card);
|
|
color: var(--text);
|
|
}
|
|
.explainer summary {
|
|
cursor: pointer;
|
|
padding: 0.6rem 1rem;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
}
|
|
.explainer-body {
|
|
padding: 0 1rem 0.75rem;
|
|
}
|
|
.explainer-body h4 {
|
|
margin: 0.9rem 0 0.25rem;
|
|
font-size: 0.95rem;
|
|
color: var(--text);
|
|
}
|
|
.explainer-body p {
|
|
margin: 0 0 0.5rem;
|
|
color: var(--text-light);
|
|
line-height: 1.5;
|
|
}
|
|
.field-help {
|
|
margin-top: 0.4rem;
|
|
}
|
|
.field-help summary {
|
|
cursor: pointer;
|
|
color: var(--text-light);
|
|
font-size: 0.85rem;
|
|
}
|
|
.field-help p {
|
|
margin: 0.35rem 0 0;
|
|
color: var(--text-light);
|
|
font-size: 0.85rem;
|
|
line-height: 1.5;
|
|
}
|
|
</style>
|