Merge wilm into wj
Brings in wilm commits: - Render script surfaces correctly in dark mode - Stop scripts reporting success after doing nothing - Correct two gate commands that pass without running - Gate the script library on the grant that pushes scripts - Let the policy list edit the rows the API accepts - Cap plugin revision ids at the 32 chars MySQL stores - Split shipped monitor bundle into per-monitor policies Resolved conflict in agent ShopDbEndpointAgent.csproj by merging: - wilm 1.14.0 script verdict and output capture features - wj 1.13.1-1.13.5 tray coordination releases - Bumped to 1.14.1 for coordinated agent+tray release
This commit is contained in:
@@ -19,7 +19,7 @@ from shopdb.api import (db, AuditLog, success_response, error_response,
|
||||
from .routes import computers_bp
|
||||
from ..models import (ConfigurationPolicy, ConfigurationPolicyAction,
|
||||
BusinessPolicy, BusinessPolicyItem, POLICY_SOURCE_LOCAL,
|
||||
POLICY_SOURCE_AUTOAPP,
|
||||
POLICY_SOURCE_AUTOAPP, POLICY_SOURCE_SHIPPED,
|
||||
ACTION_MODE_DESIRED, ACTION_MODE_GATE)
|
||||
from ..policyflag import policy_flag_enabled
|
||||
from ..services.apppolicy import disown_if_generated
|
||||
@@ -262,8 +262,12 @@ def update_configuration_policy(policy_id: int):
|
||||
'Configuration policy not found', http_code=404)
|
||||
# An autoapp row is generated from an Application's "Required on all PCs"
|
||||
# checkbox and IS editable: editing it is precisely how an admin takes
|
||||
# ownership. Genuinely external sources (shipped/managed) stay read-only.
|
||||
if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP):
|
||||
# ownership. A shipped row is editable for the same reason - the product
|
||||
# ships a starting point, not an opinion about one site's floor - and keeps
|
||||
# source='shipped' so provenance survives the edit. Genuinely external
|
||||
# sources (a future managed owner) stay read-only.
|
||||
if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP,
|
||||
POLICY_SOURCE_SHIPPED):
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
'Policy is managed externally and is read-only',
|
||||
http_code=409)
|
||||
@@ -326,9 +330,11 @@ def delete_configuration_policy(policy_id: int):
|
||||
if not policy:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'Configuration policy not found', http_code=404)
|
||||
# As with update: a generated row is the site's to remove, an externally
|
||||
# managed one is not.
|
||||
if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP):
|
||||
# As with update: a generated or shipped row is the site's to remove, an
|
||||
# externally managed one is not. A site that does not run MTConnect should
|
||||
# be able to delete its monitor policy outright rather than carry it.
|
||||
if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP,
|
||||
POLICY_SOURCE_SHIPPED):
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
'Policy is managed externally and is read-only',
|
||||
http_code=409)
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" :disabled="p.source !== 'local'" @click="openModal(p)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" :disabled="p.source !== 'local'" @click="remove(p)">Delete</button>
|
||||
<button class="btn btn-secondary btn-sm" :disabled="!iseditable(p)" @click="openModal(p)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" :disabled="!iseditable(p)" @click="remove(p)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="policies.length === 0">
|
||||
@@ -287,6 +287,18 @@ async function save() {
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the API guard in plugins/computers/api/policy_routes.py: local,
|
||||
// autoapp and shipped rows are all editable. A shipped row is a starting point
|
||||
// the product supplies, not a fixed opinion, and it keeps source='shipped' so
|
||||
// provenance survives the edit. Only a genuinely external managed owner is
|
||||
// read-only. Disabling on source==='local' alone stranded shipped rows behind
|
||||
// dead buttons while the API was accepting the write.
|
||||
const EDITABLE_SOURCES = ['local', 'autoapp', 'shipped']
|
||||
|
||||
function iseditable(p) {
|
||||
return EDITABLE_SOURCES.includes(p.source)
|
||||
}
|
||||
|
||||
async function remove(p) {
|
||||
if (!confirm(`Delete configuration policy "${p.name}"?`)) return
|
||||
try {
|
||||
|
||||
@@ -512,6 +512,90 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Run Scripts: pushes a ShopDB-hosted script from the library. Distinct
|
||||
from Corporate Tools above - that starts a task the endpoint already
|
||||
approved, this delivers script content ShopDB holds. Separate grant
|
||||
(endpoints.runscript), separate tab, so neither implies the other. -->
|
||||
<div v-show="activegroup === 'runscripts'" class="detail-full">
|
||||
<div class="card">
|
||||
<h3>Run Scripts</h3>
|
||||
<p class="corptools-intro">
|
||||
Sends a script from the ShopDB script library to this computer. The
|
||||
agent fetches the body by digest and runs it in the context the
|
||||
script itself specifies.
|
||||
</p>
|
||||
<!-- Persistent, same reasoning as the Corporate Tools warning: this
|
||||
is arbitrary code on a live machine, and the operator must still
|
||||
be able to see that when they decide to press Run. -->
|
||||
<p class="corptools-warning">
|
||||
A script here runs as SYSTEM unless it is configured to run as the
|
||||
logged-on user. It takes effect immediately on the live computer and
|
||||
cannot be undone from here.
|
||||
</p>
|
||||
|
||||
<div v-if="scriptsloading" class="runscripts-empty">Loading scripts...</div>
|
||||
<div v-else-if="runnablescripts.length === 0" class="runscripts-empty">
|
||||
No runnable scripts. A script needs an uploaded body before it can be
|
||||
sent; add one under Settings then return here.
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="form-group">
|
||||
<label for="runscript-picker">Script</label>
|
||||
<select
|
||||
id="runscript-picker"
|
||||
v-model="selectedscriptid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select a script...</option>
|
||||
<option
|
||||
v-for="script in runnablescripts"
|
||||
:key="script.scriptid"
|
||||
:value="script.scriptid"
|
||||
>{{ script.scriptname }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- What the operator is about to run, stated before they run it.
|
||||
Run context and working directory are authoring decisions made
|
||||
in the library and are shown read-only here: overriding either
|
||||
at push time would mean the script that ran is not the script
|
||||
that was reviewed. -->
|
||||
<div v-if="selectedscript" class="runscripts-detail">
|
||||
<p v-if="selectedscript.scriptdescription" class="runscripts-desc">
|
||||
{{ selectedscript.scriptdescription }}
|
||||
</p>
|
||||
<dl class="runscripts-facts">
|
||||
<dt>Runs as</dt>
|
||||
<dd>
|
||||
{{ selectedscript.runcontext === 'user'
|
||||
? 'The logged-on user' : 'SYSTEM' }}
|
||||
</dd>
|
||||
<dt>Runs in</dt>
|
||||
<dd class="mono">
|
||||
{{ selectedscript.workingdirectory || 'Agent staging folder' }}
|
||||
</dd>
|
||||
</dl>
|
||||
<p
|
||||
v-if="selectedscript.workingdirectory"
|
||||
class="runscripts-note"
|
||||
>
|
||||
This script requires that folder to exist on the computer. If it
|
||||
does not, the run fails and reports the missing folder rather
|
||||
than running somewhere else.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="runscripts-actions">
|
||||
<button
|
||||
class="btn btn-warning"
|
||||
:disabled="!selectedscript || firingScript"
|
||||
@click="confirmRunScript"
|
||||
>{{ firingScript ? 'Sending...' : 'Run Script' }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(computer.createddate) }}<template v-if="computer.createdby"> by {{ computer.createdby }}</template></span>
|
||||
@@ -686,7 +770,7 @@
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { colorStyle } from "@/utils/colorStyle"
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computersApi, applicationsApi, endpointsApi } from '@/api'
|
||||
import { computersApi, applicationsApi, endpointsApi, scriptsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||
@@ -829,6 +913,16 @@ const hasCloudIdentity = computed(() => portalTemplatesLoaded.value && Boolean(
|
||||
// during setup, which reads canDeploy before this line would otherwise run.
|
||||
const canDeploy = computed(() => authStore.hasPermission('endpoints.corptools'))
|
||||
|
||||
// Gates the Run Scripts tab. Deliberately NOT canDeploy: endpoints.corptools
|
||||
// starts a task the endpoint already holds and has allowlisted, while
|
||||
// endpoints.runscript delivers arbitrary PowerShell that runs as SYSTEM unless
|
||||
// the script pins a user context. Those were split out of the old
|
||||
// endpoints.deploy bundle for exactly this reason (see
|
||||
// ELEVATED_ACTION_PERMISSIONS in plugins/endpoints/commands.py), so hanging
|
||||
// scripts off the Corporate Tools gate would put the bundle back together in
|
||||
// the UI after the backend took it apart.
|
||||
const canRunScript = computed(() => authStore.hasPermission('endpoints.runscript'))
|
||||
|
||||
// Operator report screenshots are an uncontrolled capture of whatever was on
|
||||
// the operator's screen, so they carry their own grant rather than riding
|
||||
// endpoints.view. Declared alongside canDeploy for the same reason: detailtabs
|
||||
@@ -868,6 +962,15 @@ const detailtabs = computed(() => {
|
||||
if (canDeploy.value && liveness.value) {
|
||||
tabs.push({ key: 'corptools', label: 'Corporate Tools' })
|
||||
}
|
||||
// Run Scripts is its own tab rather than a card in Corporate Tools because it
|
||||
// is a different grant and a different trust model - a library of arbitrary
|
||||
// PowerShell delivered by ShopDB, versus a closed set of tasks the endpoint
|
||||
// already approved. Liveness-gated for the same reason as Corporate Tools:
|
||||
// the command queues against a live agent, so offering it for a machine that
|
||||
// is not checking in only produces a command nothing will collect.
|
||||
if (canRunScript.value && liveness.value) {
|
||||
tabs.push({ key: 'runscripts', label: 'Run Scripts' })
|
||||
}
|
||||
return tabs
|
||||
})
|
||||
|
||||
@@ -1377,6 +1480,47 @@ const corporateTools = [
|
||||
|
||||
const firingTask = ref('')
|
||||
|
||||
// Run Scripts state. The library is fetched rather than hardcoded (the opposite
|
||||
// of corporateTools above) because these are site-authored scripts that change
|
||||
// without an agent release.
|
||||
const scriptlibrary = ref([])
|
||||
const scriptsloading = ref(false)
|
||||
const scriptsloaded = ref(false)
|
||||
const selectedscriptid = ref('')
|
||||
const firingScript = ref(false)
|
||||
|
||||
// Only scripts that can actually run. A row with no uploaded body is rejected
|
||||
// at enqueue by _resolve_run_script, so listing it would offer a button whose
|
||||
// only outcome is an error; the library page is where that gets fixed.
|
||||
const runnablescripts = computed(
|
||||
() => scriptlibrary.value.filter((script) => script.hasbody))
|
||||
|
||||
const selectedscript = computed(
|
||||
() => runnablescripts.value.find(
|
||||
(script) => String(script.scriptid) === String(selectedscriptid.value)) || null)
|
||||
|
||||
// Lazy and permission-gated: an operator without endpoints.runscript never has
|
||||
// the tab, and so never issues a request the server would refuse anyway.
|
||||
async function loadScriptLibrary() {
|
||||
if (scriptsloaded.value || scriptsloading.value) return
|
||||
scriptsloading.value = true
|
||||
try {
|
||||
const response = await scriptsApi.list({ active: 'true' })
|
||||
scriptlibrary.value = response.data.data || []
|
||||
scriptsloaded.value = true
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Failed to load the script library'))
|
||||
} finally {
|
||||
scriptsloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch on first view of the tab rather than on page load: most visits to a PC
|
||||
// never open this, and the library is irrelevant to every other tab.
|
||||
watch(activegroup, (group) => {
|
||||
if (group === 'runscripts' && canRunScript.value) loadScriptLibrary()
|
||||
})
|
||||
|
||||
// Elevated: queues a scheduledtask_run. The confirm gate mirrors
|
||||
// confirmRestart - these start real work on a live machine and some of them
|
||||
// change system state - and the server re-checks endpoints.corptools regardless.
|
||||
@@ -1412,6 +1556,41 @@ async function confirmRunTask(tool) {
|
||||
}
|
||||
}
|
||||
|
||||
// Elevated: queues a run_script. commands.py asks for this to be gated "the way
|
||||
// system_restart is gated", so the confirmation is TYPED rather than an OK
|
||||
// button - and unlike Corporate Tools there is no non-destructive branch,
|
||||
// because every script here is arbitrary code by definition. The confirm names
|
||||
// the context and the folder so the operator sees what the library decided, not
|
||||
// just the script's name.
|
||||
async function confirmRunScript() {
|
||||
const script = selectedscript.value
|
||||
if (!script) return
|
||||
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
||||
const context = script.runcontext === 'user'
|
||||
? 'the logged-on user'
|
||||
: 'SYSTEM'
|
||||
const folder = script.workingdirectory || 'the agent staging folder'
|
||||
const typed = prompt(`Run "${script.scriptname}" on ${label}\n\n`
|
||||
+ `It runs as ${context}, in ${folder}.\n`
|
||||
+ 'It starts immediately and cannot be undone from here.'
|
||||
+ '\n\nType RUN to confirm.')
|
||||
if (typed !== 'RUN') return
|
||||
|
||||
firingScript.value = true
|
||||
try {
|
||||
await endpointsApi.enqueueCommand(
|
||||
computer.value.assetid, 'run_script', `script:${script.scriptid}`)
|
||||
// "Queued", not "finished": the agent collects this on its next check-in,
|
||||
// and the script's own result comes back separately. Saying "ran" here
|
||||
// would claim an outcome nothing has observed yet.
|
||||
toast.success(`${script.scriptname} queued for ${label}`)
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, `Failed to queue ${script.scriptname}`))
|
||||
} finally {
|
||||
firingScript.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Retire, not delete: the server deactivates the asset, so the PC
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
@@ -1816,7 +1995,7 @@ async function confirmRetire() {
|
||||
it does before the confirm gate rather than after. */
|
||||
.corptools-intro {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-light);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
/* Warning weight without alert-banner styling: it is permanent, so a loud
|
||||
@@ -1825,9 +2004,9 @@ async function confirmRetire() {
|
||||
.corptools-warning {
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-left: 3px solid var(--warning, #d97706);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-left: 3px solid var(--warning);
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.corptools-list {
|
||||
@@ -1848,7 +2027,7 @@ async function confirmRetire() {
|
||||
font-weight: 600;
|
||||
}
|
||||
.corptools-desc {
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-light);
|
||||
font-size: 0.8125rem;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
@@ -1858,12 +2037,61 @@ async function confirmRetire() {
|
||||
.corptools-rowwarning {
|
||||
margin: 0.25rem 0 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-left: 3px solid var(--danger, #dc2626);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-left: 3px solid var(--danger);
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
/* Run Scripts reuses the Corporate Tools intro/warning styles above; only the
|
||||
picker-specific pieces are new. */
|
||||
.runscripts-empty {
|
||||
padding: 0.75rem 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.runscripts-detail {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.runscripts-desc {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--text-light);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
/* Two columns so the label and its value stay on one line: the folder is the
|
||||
fact most likely to be skimmed, and wrapping it under its own label made it
|
||||
read as body copy in review. */
|
||||
.runscripts-facts {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.25rem 0.75rem;
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.runscripts-facts dt {
|
||||
color: var(--text-light);
|
||||
}
|
||||
.runscripts-facts dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
/* Defined locally: .mono is not a global utility in this build, and a path
|
||||
rendered in the body font is harder to read back against the endpoint. */
|
||||
.runscripts-facts dd.mono {
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
word-break: break-all;
|
||||
}
|
||||
.runscripts-note {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.runscripts-actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.triage-chip.critical .triage-dot { background: var(--danger); }
|
||||
.triage-chip.ok { color: var(--success-dark); }
|
||||
.triage-chip.warning { color: var(--warning-dark); }
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const view = readFileSync(join(here, 'ConfigurationPoliciesList.vue'), 'utf8')
|
||||
|
||||
// The API decides what may be edited; the buttons only reflect that decision.
|
||||
// These drifted once: policy_routes.py was widened to accept shipped rows while
|
||||
// the buttons still disabled on source !== 'local', so every shipped policy sat
|
||||
// behind a dead Edit button and the change looked like it had never rolled.
|
||||
// If the API guard changes, EDITABLE_SOURCES here changes with it.
|
||||
describe('the configuration policy buttons follow the API guard', () => {
|
||||
it('treats shipped as editable, not read-only', () => {
|
||||
expect(view).toContain("EDITABLE_SOURCES = ['local', 'autoapp', 'shipped']")
|
||||
})
|
||||
|
||||
it('does not gate the buttons on local alone', () => {
|
||||
expect(view).not.toContain(":disabled=\"p.source !== 'local'\"")
|
||||
})
|
||||
|
||||
it('gates edit and delete on the same rule', () => {
|
||||
const disabled = view.match(/:disabled="!iseditable\(p\)"/g) || []
|
||||
expect(disabled).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
95
plugins/computers/frontend/views/pcDetailRunScripts.spec.js
Normal file
95
plugins/computers/frontend/views/pcDetailRunScripts.spec.js
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Run Scripts tab on PC Details.
|
||||
*
|
||||
* The thing worth pinning here is the SEPARATION. run_script and
|
||||
* scheduledtask_run map to different grants in plugins/endpoints/commands.py
|
||||
* (endpoints.runscript and endpoints.corptools), split out of a single
|
||||
* endpoints.deploy bundle precisely so that being able to trigger a corporate
|
||||
* tool does not also mean being able to run arbitrary PowerShell as SYSTEM.
|
||||
*
|
||||
* That split lives in the backend. The UI can quietly undo it by hanging the
|
||||
* script picker off the Corporate Tools tab, whose only gate is
|
||||
* endpoints.corptools - and the failure would be invisible in review, because
|
||||
* the page would look correct and the server would simply 403 the push. These
|
||||
* tests fail if someone merges the two.
|
||||
*
|
||||
* Asserted against the file text, matching systemScriptsSettings.spec.js.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const source = readFileSync(join(here, 'PCDetail.vue'), 'utf8')
|
||||
|
||||
describe('PCDetail Run Scripts tab', () => {
|
||||
it('gates the tab on endpoints.runscript, not the Corporate Tools grant', () => {
|
||||
expect(source).toMatch(
|
||||
/const canRunScript = computed\(\(\) => authStore\.hasPermission\('endpoints\.runscript'\)\)/)
|
||||
})
|
||||
|
||||
it('pushes run_script from its own tab, not from the Corporate Tools block', () => {
|
||||
// The corptools block must not gain a run_script call: that would put the
|
||||
// action behind endpoints.corptools in the UI while the server still
|
||||
// demands endpoints.runscript.
|
||||
const corptools = source.slice(
|
||||
source.indexOf("activegroup === 'corptools'"),
|
||||
source.indexOf("activegroup === 'runscripts'"))
|
||||
expect(corptools).not.toMatch(/run_script/)
|
||||
expect(source).toMatch(/activegroup === 'runscripts'/)
|
||||
})
|
||||
|
||||
it('enqueues run_script with a script-id target', () => {
|
||||
expect(source).toMatch(/'run_script', `script:\$\{script\.scriptid\}`/)
|
||||
})
|
||||
|
||||
it('requires a typed confirmation before running', () => {
|
||||
// Every script on this tab is arbitrary code, so unlike Corporate Tools
|
||||
// there is no safe branch that gets a plain OK button.
|
||||
const handler = source.slice(source.indexOf('async function confirmRunScript'))
|
||||
const body = handler.slice(0, handler.indexOf('\n}\n'))
|
||||
expect(body).toMatch(/Type RUN to confirm/)
|
||||
expect(body).toMatch(/typed !== 'RUN'/)
|
||||
})
|
||||
|
||||
it('warns that a script runs as SYSTEM before it is sent', () => {
|
||||
const tab = source.slice(source.indexOf("activegroup === 'runscripts'"))
|
||||
expect(tab.slice(0, 2000)).toMatch(/runs as SYSTEM/)
|
||||
})
|
||||
|
||||
it('shows the run context and working directory read-only', () => {
|
||||
// Both are authoring decisions made in the library. Offering an override
|
||||
// here would mean the script that ran is not the script that was reviewed,
|
||||
// so they must be rendered as facts and never bound with v-model.
|
||||
const tab = source.slice(source.indexOf("activegroup === 'runscripts'"))
|
||||
const block = tab.slice(0, tab.indexOf('runscripts-actions'))
|
||||
expect(block).toMatch(/selectedscript\.workingdirectory/)
|
||||
expect(block).toMatch(/selectedscript\.runcontext/)
|
||||
expect(block).not.toMatch(/v-model="[^"]*workingdirectory/)
|
||||
expect(block).not.toMatch(/v-model="[^"]*runcontext/)
|
||||
})
|
||||
|
||||
it('offers only scripts that have a body', () => {
|
||||
// A bodyless script is rejected at enqueue by _resolve_run_script, so
|
||||
// listing it would offer a button whose only outcome is an error.
|
||||
expect(source).toMatch(/filter\(\(script\) => script\.hasbody\)/)
|
||||
})
|
||||
|
||||
it('reports the push as queued rather than as finished', () => {
|
||||
// The agent collects the command on its next check-in; claiming it ran
|
||||
// would assert an outcome nothing has observed.
|
||||
const handler = source.slice(source.indexOf('async function confirmRunScript'))
|
||||
expect(handler.slice(0, handler.indexOf('\n}\n'))).toMatch(/queued for/)
|
||||
})
|
||||
|
||||
it('styles the Run Scripts tab from defined theme tokens only', () => {
|
||||
// --text-primary/--text-secondary/--bg-secondary are not defined by this
|
||||
// app's theme. var() then falls through to the fallback, or to inherit,
|
||||
// which reads fine in light mode and misrenders for dark-mode users - the
|
||||
// failure only shows up on the theme most of the shop floor runs.
|
||||
const runscripts = source.slice(source.indexOf('.corptools-intro'))
|
||||
expect(runscripts).not.toMatch(/--text-primary|--text-secondary|--bg-secondary|--surface/)
|
||||
expect(runscripts).not.toMatch(/var\(--[a-z-]+,\s*#/)
|
||||
})
|
||||
})
|
||||
@@ -27,10 +27,21 @@ from .jsontext import JSONText
|
||||
# owner. Kept a plain string, not an enum column, so adding a source is data.
|
||||
POLICY_SOURCE_LOCAL = 'local'
|
||||
|
||||
# Product-owned rows we ship (the shopfloor app monitor bundle). Same read-only
|
||||
# treatment as any non-local source: the CRUD API refuses to edit or delete
|
||||
# them, the seeder reconciles them. Distinct from 'local' so a site's own
|
||||
# policies are never confused with what the product shipped.
|
||||
# Rows the product seeds (the shopfloor monitor policies, one per monitor).
|
||||
# Distinct from 'local' so a site's own policies are never confused with what
|
||||
# the product shipped, and so the UI can badge the origin.
|
||||
#
|
||||
# A shipped row is EDITABLE and DELETABLE, unlike a genuinely external source.
|
||||
# The product ships a starting point, not an opinion about one site's floor: a
|
||||
# plant that runs no MTConnect must be able to drop that policy. source stays
|
||||
# 'shipped' through an edit, so provenance is a fact about where the row came
|
||||
# from rather than a claim about who controls it now.
|
||||
#
|
||||
# Nothing reconciles these rows. The seeding migrations are insert-if-absent
|
||||
# (they SELECT by name and return early if it exists), so an upgrade cannot
|
||||
# stomp a site's edits. That property is load-bearing now that the rows are
|
||||
# editable - do not replace it with a reconciling seeder without adding a
|
||||
# dirty flag first.
|
||||
POLICY_SOURCE_SHIPPED = 'shipped'
|
||||
|
||||
# Rows generated FOR US by another surface rather than authored by hand: today
|
||||
|
||||
@@ -465,6 +465,9 @@ Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
|
||||
connections with a `truncated` flag in `detail`, because an oversized payload
|
||||
rejected by the server returns nothing at all and a partial answer is more
|
||||
useful than a failed one. Standard reports carry listening ports only.
|
||||
- `run_script` - run a reviewed PowerShell script from the script library. The
|
||||
body is NOT carried on the command channel; the claim carries a descriptor and
|
||||
the agent fetches the bytes separately (section 7.4).
|
||||
|
||||
### 7.1 Claim the next command (long-poll)
|
||||
|
||||
@@ -509,3 +512,69 @@ Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
|
||||
gated by user JWT + `endpoints.command`. Only one command may be in flight per
|
||||
endpoint (a second enqueue returns HTTP 409 until the first finalizes).
|
||||
|
||||
### 7.4 `run_script` claim descriptor
|
||||
|
||||
A `run_script` claim carries a `script` object alongside the usual
|
||||
`endpointcommandid` and `action`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"scriptid": 12,
|
||||
"scriptname": "sfld_autologon",
|
||||
"installpath": "/api/endpoints/scriptbody/12", // fetch with X-API-Key
|
||||
"sha256": "3f9a...", // 64 hex chars, lowercase
|
||||
"runcontext": "system", // "system" | "user"
|
||||
"workingdirectory": "C:\\Tools", // "" = agent staging folder
|
||||
"errormode": "default" // "default" | "strict"
|
||||
}
|
||||
```
|
||||
|
||||
The agent MUST download `installpath`, verify the SHA-256 of the received bytes
|
||||
against `sha256`, and refuse to run on mismatch. The body never travels on the
|
||||
command channel so a queued command cannot be used to smuggle code past review.
|
||||
|
||||
`workingdirectory` is the folder the script runs from. Empty means the agent's
|
||||
staging directory, which is correct for a self-contained script and wrong for
|
||||
one invoking a separately-installed tool by relative path. A pinned folder that
|
||||
does not exist MUST fail the command rather than falling back, so the script
|
||||
never runs somewhere unintended.
|
||||
|
||||
`errormode` selects how the run is judged:
|
||||
|
||||
- `default` - PowerShell's normal behaviour. Most errors are non-terminating:
|
||||
the script writes the error, continues, and still exits zero.
|
||||
- `strict` - the agent prepends `$ErrorActionPreference = 'Stop'` so the first
|
||||
error terminates the run and the exit code reflects reality.
|
||||
|
||||
`default` is the default precisely BECAUSE it is permissive. Every script
|
||||
authored before this field existed relies on that behaviour, and some rely on it
|
||||
deliberately - tolerating a failing cleanup, probing with a cmdlet that errors
|
||||
when a thing is absent, looping past a bad item. Promoting `strict` globally
|
||||
would make those bodies abort partway and leave endpoints half-configured, so it
|
||||
is opt-in per script.
|
||||
|
||||
Two consequences the agent MUST honour:
|
||||
|
||||
- The strict prologue is applied AFTER digest verification, so the hash still
|
||||
validates the bytes the server actually delivered.
|
||||
- Because the prologue adds a line, an error reported on line N of a strict run
|
||||
refers to line N-1 of the stored body. Surfaces showing the body should say so.
|
||||
|
||||
An agent that does not recognise an `errormode` value MUST treat it as
|
||||
`default`. The server always sends the field explicitly, even at the default, so
|
||||
an agent that received it and ignored it stays distinguishable from one that
|
||||
never saw it.
|
||||
|
||||
#### Script output
|
||||
|
||||
Script stdout/stderr is ALWAYS written to the local agent log on the endpoint.
|
||||
It is returned to the server in `detail.output` only when the command FAILED.
|
||||
Routine output stays on the machine that produced it: shipping every successful
|
||||
run back would put arbitrary script output, including whatever an author printed
|
||||
while debugging, into the server's command history for no diagnostic gain.
|
||||
Output is truncated to 8000 characters and flagged when truncated, because an
|
||||
oversized payload rejected by the server returns nothing at all.
|
||||
|
||||
Timed-out runs do not carry output: the script is still running and still holds
|
||||
the capture file open, so anything read at that moment would be misleading.
|
||||
|
||||
|
||||
@@ -2072,9 +2072,15 @@ def _resolve_run_script(target):
|
||||
"""Resolve a run_script target to a script delivery descriptor.
|
||||
|
||||
Accepts either "script:<scriptid>" or a bare script name. The descriptor
|
||||
carries a collector-authenticated download URL, the body's SHA-256, and the
|
||||
per-script run context (system or user). The agent fetches the body with
|
||||
its collector credential and runs it; the body never crosses the wire.
|
||||
carries a collector-authenticated download URL, the body's SHA-256, the
|
||||
per-script run context (system or user), and the optional folder to run in.
|
||||
The agent fetches the body with its credential and runs it; the body never
|
||||
crosses the wire.
|
||||
|
||||
workingdirectory is sent even when empty so the field is always present in
|
||||
the claim: an agent that receives it and ignores it is then distinguishable
|
||||
from one that never saw it, which matters while agents of both vintages are
|
||||
in the field. errormode rides the same rule, and for the same reason.
|
||||
"""
|
||||
kind, _sep, raw = str(target or '').partition(':')
|
||||
script = None
|
||||
@@ -2102,6 +2108,8 @@ def _resolve_run_script(target):
|
||||
'installpath': '/api/endpoints/scriptbody/{}'.format(script.scriptid),
|
||||
'sha256': script.sha256,
|
||||
'runcontext': script.runcontext,
|
||||
'workingdirectory': script.workingdirectory or '',
|
||||
'errormode': script.errormode or 'default',
|
||||
}, None
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,20 @@ export default [
|
||||
name: 'endpoints-monitors-settings',
|
||||
component: () => import('./views/MonitorsSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
|
||||
},
|
||||
// requiresPermission, not requiresAdmin: endpoints.runscript is grantable to
|
||||
// non-admin operators, and the sibling pages' admin gate would lock the
|
||||
// holders of the grant out of the library they are trusted to manage. The
|
||||
// API enforces the same permission, so this only keeps the menu honest.
|
||||
{
|
||||
path: 'settings/endpoints/scripts',
|
||||
name: 'endpoints-scripts-settings',
|
||||
component: () => import('./views/SystemScriptsSettings.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresPermission: 'endpoints.runscript',
|
||||
plugin: 'endpoints'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
412
plugins/endpoints/frontend/views/SystemScriptsSettings.vue
Normal file
412
plugins/endpoints/frontend/views/SystemScriptsSettings.vue
Normal file
@@ -0,0 +1,412 @@
|
||||
<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>
|
||||
</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.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',
|
||||
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',
|
||||
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,
|
||||
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>
|
||||
124
plugins/endpoints/frontend/views/systemScriptsSettings.spec.js
Normal file
124
plugins/endpoints/frontend/views/systemScriptsSettings.spec.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* System Scripts settings page.
|
||||
*
|
||||
* Two things are worth pinning here and nothing else is:
|
||||
*
|
||||
* 1. The page must NOT offer a run context. Scripts run as SYSTEM because the
|
||||
* agent is a service with no user session; exposing a picker would let
|
||||
* someone author a script that reads as "run as the logged-on user" and
|
||||
* silently does not.
|
||||
* 2. The SYSTEM warning must be present on both the list and the add/edit
|
||||
* form. Someone pasting a script into the modal may never have read the
|
||||
* page copy above it.
|
||||
*
|
||||
* Asserted against the file text, matching configurationPolicyEditable.spec.js.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const source = readFileSync(join(here, 'SystemScriptsSettings.vue'), 'utf8')
|
||||
const routes = readFileSync(join(here, '..', 'routes.js'), 'utf8')
|
||||
|
||||
describe('SystemScriptsSettings', () => {
|
||||
it('warns that scripts run as SYSTEM on the list page and in the form', () => {
|
||||
const warnings = source.match(/runs? as SYSTEM/g) || []
|
||||
expect(warnings.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('does not offer a run context picker', () => {
|
||||
expect(source).not.toMatch(/runcontext/i)
|
||||
})
|
||||
|
||||
it('does not send a runcontext in its save payload', () => {
|
||||
const payload = source.slice(source.indexOf('const payload = {'))
|
||||
expect(payload.slice(0, 200)).not.toMatch(/runcontext/i)
|
||||
})
|
||||
|
||||
it('flags a script with no body as unrunnable', () => {
|
||||
expect(source).toMatch(/hasbody/)
|
||||
expect(source).toMatch(/cannot run/i)
|
||||
})
|
||||
|
||||
it('is gated on the standalone script permission, not admin', () => {
|
||||
const route = routes.slice(routes.indexOf("'settings/endpoints/scripts'"))
|
||||
expect(route).toMatch(/requiresPermission: 'endpoints\.runscript'/)
|
||||
expect(route.slice(0, 300)).not.toMatch(/requiresAdmin/)
|
||||
})
|
||||
|
||||
it('offers an optional working directory and sends it on save', () => {
|
||||
expect(source).toMatch(/form\.workingdirectory/)
|
||||
const payload = source.slice(source.indexOf('const payload = {'))
|
||||
expect(payload.slice(0, 300)).toMatch(/workingdirectory/)
|
||||
})
|
||||
|
||||
it('shows the staging default rather than an empty cell', () => {
|
||||
// A blank cell reads as "unknown". The folder is the difference between a
|
||||
// corporate tool being found and the script failing further in, so the
|
||||
// list has to state the default out loud.
|
||||
expect(source).toMatch(/Agent staging folder/)
|
||||
})
|
||||
|
||||
it('offers an error mode and sends it on save', () => {
|
||||
expect(source).toMatch(/form\.errormode/)
|
||||
const payload = source.slice(source.indexOf('const payload = {'))
|
||||
expect(payload.slice(0, 300)).toMatch(/errormode/)
|
||||
})
|
||||
|
||||
it('defaults a new script to the permissive mode', () => {
|
||||
// Strict changes how a body behaves partway through. Defaulting to it
|
||||
// would silently alter every script authored from here on, so a new
|
||||
// script has to start where the old ones already are.
|
||||
const empty = source.slice(source.indexOf('function emptyForm'))
|
||||
expect(empty.slice(0, 300)).toMatch(/errormode: 'default'/)
|
||||
})
|
||||
|
||||
it('names both error modes by their consequence, not by their flag', () => {
|
||||
// "default" and "strict" mean nothing to the person choosing. What they
|
||||
// need to know is which one can report success having done nothing.
|
||||
expect(source).toMatch(/Continue - report success if the script reaches the end/)
|
||||
expect(source).toMatch(/Stop on the first error and fail the command/)
|
||||
})
|
||||
|
||||
it('explains the non-terminating error trap that justifies the choice', () => {
|
||||
// The whole feature exists because PowerShell exits zero after an error.
|
||||
// If the page never says so, the dropdown looks like pedantry and stays
|
||||
// on the setting that produces false green.
|
||||
expect(source).toMatch(/non-terminating/i)
|
||||
expect(source).toMatch(/exits? zero/i)
|
||||
})
|
||||
|
||||
it('offers the explainer collapsed so it does not bury the page', () => {
|
||||
expect(source).toMatch(/<details class="explainer">/)
|
||||
expect(source).toMatch(/How does this work\?/)
|
||||
expect(source).not.toMatch(/<details class="explainer" open>/)
|
||||
})
|
||||
|
||||
it('collapses the long per-field prose in the form', () => {
|
||||
// The modal is already tall enough to scroll. Three paragraphs of standing
|
||||
// explanation between the inputs pushes Save off screen and gets skimmed
|
||||
// past, which is the opposite of what the copy is for.
|
||||
const helps = source.match(/<details class="field-help">/g) || []
|
||||
expect(helps.length).toBeGreaterThanOrEqual(2)
|
||||
expect(source).toMatch(/<summary>How this works<\/summary>/)
|
||||
})
|
||||
|
||||
it('styles itself from defined theme tokens only', () => {
|
||||
// --surface and --text-primary are not defined by this app's theme, so a
|
||||
// var() fallback silently rendered a white panel on the dark background.
|
||||
// A fallback colour is the bug: it looks correct in light mode and only
|
||||
// shows up as a bright block for dark-mode users.
|
||||
const styles = source.slice(source.indexOf('<style scoped>'))
|
||||
expect(styles).not.toMatch(/--surface|--text-primary|--text-secondary|--bg-secondary/)
|
||||
expect(styles).not.toMatch(/var\(--[a-z-]+,\s*#/)
|
||||
})
|
||||
|
||||
it('states that output is kept locally and only returned on failure', () => {
|
||||
// Operators otherwise assume either that nothing was captured or that
|
||||
// every run is shipped back to the server.
|
||||
const explainer = source.slice(source.indexOf('Where the output goes'))
|
||||
expect(explainer.slice(0, 400)).toMatch(/only when the run fails/i)
|
||||
})
|
||||
})
|
||||
@@ -13,8 +13,9 @@ so the catalog matches what a deployed agent already watches:
|
||||
process agent.exe -> MTConnect
|
||||
service MTConnect UDC Agent -> MTConnect
|
||||
|
||||
Shipped rows are product-owned (source='shipped'): the CRUD API refuses to edit
|
||||
or delete them. Launch paths mirror the agent's launchpaths block; collector and
|
||||
Shipped rows (source='shipped') keep their kind and displayname - that pair is
|
||||
the identity the monitor policies match on - but a site may edit their paths and
|
||||
active flag. Launch paths mirror the agent's launchpaths block; collector and
|
||||
log paths are left null until site paths are supplied.
|
||||
|
||||
Idempotent: creation checks the live schema first so a db.create_all() site
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
"""Ship the shopfloor app monitors as a read-only Site Policy.
|
||||
"""Ship the shopfloor app monitors as a Site Policy bundle.
|
||||
|
||||
SUPERSEDED BY 0019. This migration is kept because installs have stamped it, but
|
||||
what it seeds no longer survives an upgrade: 0019 splits this single bundle into
|
||||
one Configuration Policy per monitor and REMOVES the shipped Site Policy, so
|
||||
sites compose their own. Read this for history; do not extend it.
|
||||
|
||||
Two claims below were true when written and are now false, corrected here rather
|
||||
than left to mislead: the seeded rows are NOT read-only (0019 admits 'shipped'
|
||||
at the policy CRUD guards), and no seeder reconciles them - the idempotency
|
||||
check at the top of upgrade() returns early if the policy exists, which is what
|
||||
keeps a site's edits safe across upgrades.
|
||||
|
||||
Wraps the four shipped endpointmonitors (eDNC, Universal Data Collector,
|
||||
MTConnect process, MTConnect UDC Agent service) in a product-owned policy bundle
|
||||
so the agent stops carrying them as hardcoded appsettings.json defaults and reads
|
||||
its watch-list from the server instead.
|
||||
MTConnect process, MTConnect UDC Agent service) in a policy bundle so the agent
|
||||
stops carrying them as hardcoded appsettings.json defaults and reads its
|
||||
watch-list from the server instead.
|
||||
|
||||
Seeds two computers-plugin rows, both source='shipped' (read-only in the UI, the
|
||||
CRUD API refuses to edit or delete them):
|
||||
Seeds two computers-plugin rows, both source='shipped':
|
||||
|
||||
ConfigurationPolicy "Shopfloor App Monitors"
|
||||
scope {"type":"true"} (every endpoint), one action per shipped monitor,
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Split the shipped monitor bundle into one policy per monitor.
|
||||
|
||||
0007 seeded a single Configuration Policy "Shopfloor App Monitors" carrying all
|
||||
four shipped monitor actions, wrapped in a shipped Site (business) Policy of the
|
||||
same name. That bundle was all-or-nothing: a site that runs eDNC but no
|
||||
MTConnect could not detach the half it does not have, because the whole row was
|
||||
product-owned and read-only.
|
||||
|
||||
This migration replaces it with one Configuration Policy per shipped monitor:
|
||||
|
||||
Monitor: eDNC (process)
|
||||
Monitor: Universal Data Collector (process)
|
||||
Monitor: MTConnect (process)
|
||||
Monitor: MTConnect (service)
|
||||
|
||||
Each carries exactly one 'monitor:<endpointmonitorid>' action, mode='gate',
|
||||
scope {"type":"true"} - no PC type attribution. The name carries the kind
|
||||
because displayname alone is not unique: MTConnect ships as both a process and
|
||||
a service, and configurationpolicies.name is UNIQUE.
|
||||
|
||||
source stays 'shipped'. Provenance is still true - the product did ship these -
|
||||
and the UI keeps showing where they came from. What changes is that the CRUD
|
||||
guards now admit 'shipped' for edit/delete, so a site can retune or drop one
|
||||
without losing the record of its origin.
|
||||
|
||||
The shipped SITE policy is REMOVED and not replaced. Composition is a site
|
||||
decision: which monitors apply to which endpoints, and at what priority, is
|
||||
exactly the judgement a plant makes about its own floor. Shipping one meant
|
||||
every install inherited "apply all four to every endpoint" as a starting point
|
||||
that could not be edited. Sites now build their own from the per-monitor pieces.
|
||||
|
||||
Removing it is the behaviour change to be aware of on upgrade: monitors that
|
||||
were attached through that bundle stop being attached until a site policy
|
||||
composes them. It is deliberate and it is the point of the split - see the
|
||||
"off by default" direction in the 2026-09-08 plan - but it is not silent, since
|
||||
the per-monitor policies are listed and ready to compose.
|
||||
|
||||
Idempotent on the policy names, so a re-run neither duplicates the split rows
|
||||
nor resurrects the bundle. Guards on the policy tables being present and no-ops
|
||||
if they are not, matching 0007.
|
||||
|
||||
Revision ID: endpoints0019splitpolicies
|
||||
Revises: endpoints0018endpointlocks
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# Alembic stores this in alembic_version.version_num, a VARCHAR(32). A longer
|
||||
# id passes on SQLite and fails on strict MySQL with "Data too long".
|
||||
revision = 'endpoints0019splitpolicies'
|
||||
down_revision = 'endpoints0018endpointlocks'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
BUNDLE_NAME = 'Shopfloor App Monitors'
|
||||
|
||||
# The shipped monitors, by identity (kind, displayname) - never by hardcoded id,
|
||||
# so this survives id drift across installs exactly as 0007 does.
|
||||
_SHIPPED_MONITORS = (
|
||||
('process', 'eDNC'),
|
||||
('process', 'Universal Data Collector'),
|
||||
('process', 'MTConnect'),
|
||||
('service', 'MTConnect'),
|
||||
)
|
||||
|
||||
_REQUIRED = {'endpointmonitors', 'configurationpolicies',
|
||||
'configurationpolicyactions', 'businesspolicies',
|
||||
'businesspolicyitems'}
|
||||
|
||||
|
||||
def _tables(bind):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
return set(inspect(bind).get_table_names())
|
||||
|
||||
|
||||
def _policy_name(kind, displayname):
|
||||
return f'Monitor: {displayname} ({kind})'
|
||||
|
||||
|
||||
def _policy_description(kind, displayname):
|
||||
noun = 'process' if kind == 'process' else 'service'
|
||||
return (f'Product-shipped watch-list entry for the {displayname} {noun}. '
|
||||
f'Attach it to a Site Policy to apply it to endpoints.')
|
||||
|
||||
|
||||
def _config_policy_id(bind, name):
|
||||
return bind.execute(
|
||||
sa.text('SELECT configurationpolicyid FROM configurationpolicies '
|
||||
'WHERE name = :name'),
|
||||
{'name': name}).scalar()
|
||||
|
||||
|
||||
def _drop_config_policy(bind, name):
|
||||
"""Delete a configuration policy, its actions, and any items citing it."""
|
||||
policyid = _config_policy_id(bind, name)
|
||||
if policyid is None:
|
||||
return
|
||||
# Clear children explicitly rather than lean on ON DELETE CASCADE, which
|
||||
# sqlite does not enforce by default.
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM businesspolicyitems '
|
||||
'WHERE configurationpolicyid = :id'), {'id': policyid})
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM configurationpolicyactions '
|
||||
'WHERE configurationpolicyid = :id'), {'id': policyid})
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM configurationpolicies '
|
||||
'WHERE configurationpolicyid = :id'), {'id': policyid})
|
||||
|
||||
|
||||
def _drop_business_policy(bind, name):
|
||||
policyid = bind.execute(
|
||||
sa.text('SELECT businesspolicyid FROM businesspolicies '
|
||||
'WHERE name = :name AND source = :source'),
|
||||
{'name': name, 'source': 'shipped'}).scalar()
|
||||
if policyid is None:
|
||||
return
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM businesspolicyitems WHERE businesspolicyid = :id'),
|
||||
{'id': policyid})
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM businesspolicies WHERE businesspolicyid = :id'),
|
||||
{'id': policyid})
|
||||
|
||||
|
||||
def _monitor_id(bind, kind, displayname):
|
||||
return bind.execute(
|
||||
sa.text('SELECT endpointmonitorid FROM endpointmonitors '
|
||||
'WHERE kind = :kind AND displayname = :displayname'),
|
||||
{'kind': kind, 'displayname': displayname}).scalar()
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
if not _REQUIRED.issubset(_tables(bind)):
|
||||
return
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
config_policies = sa.table(
|
||||
'configurationpolicies',
|
||||
sa.column('name', sa.String),
|
||||
sa.column('description', sa.Text),
|
||||
sa.column('scopejson', sa.Text),
|
||||
sa.column('source', sa.String),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
actions = sa.table(
|
||||
'configurationpolicyactions',
|
||||
sa.column('configurationpolicyid', sa.Integer),
|
||||
sa.column('action', sa.String),
|
||||
sa.column('mode', sa.String),
|
||||
sa.column('desiredstate', sa.Text),
|
||||
sa.column('params', sa.Text),
|
||||
sa.column('intervalminutes', sa.Integer),
|
||||
sa.column('sortorder', sa.Integer),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
|
||||
for kind, displayname in _SHIPPED_MONITORS:
|
||||
name = _policy_name(kind, displayname)
|
||||
if _config_policy_id(bind, name) is not None:
|
||||
continue
|
||||
monitorid = _monitor_id(bind, kind, displayname)
|
||||
if monitorid is None:
|
||||
# A site that never had this monitor gets no policy for it.
|
||||
continue
|
||||
|
||||
op.bulk_insert(config_policies, [{
|
||||
'name': name,
|
||||
'description': _policy_description(kind, displayname),
|
||||
'scopejson': '{"type": "true"}',
|
||||
'source': 'shipped',
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
op.bulk_insert(actions, [{
|
||||
'configurationpolicyid': _config_policy_id(bind, name),
|
||||
'action': f'monitor:{monitorid}',
|
||||
'mode': 'gate',
|
||||
'desiredstate': None,
|
||||
'params': None,
|
||||
'intervalminutes': None,
|
||||
'sortorder': 0,
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
|
||||
# Retire the monolith. The business policy goes first so its item rows are
|
||||
# gone before the configuration policy it cited is removed.
|
||||
_drop_business_policy(bind, BUNDLE_NAME)
|
||||
_drop_config_policy(bind, BUNDLE_NAME)
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Reinstate the single shipped bundle and drop the per-monitor policies.
|
||||
|
||||
Rebuilt from the same monitor identities rather than from anything the
|
||||
upgrade stashed, so it lands in the state 0007 produces regardless of what
|
||||
a site did to the split rows in between.
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
if not _REQUIRED.issubset(_tables(bind)):
|
||||
return
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
monitor_ids = []
|
||||
for kind, displayname in _SHIPPED_MONITORS:
|
||||
monitorid = _monitor_id(bind, kind, displayname)
|
||||
if monitorid is not None:
|
||||
monitor_ids.append(monitorid)
|
||||
|
||||
for kind, displayname in _SHIPPED_MONITORS:
|
||||
_drop_config_policy(bind, _policy_name(kind, displayname))
|
||||
|
||||
if not monitor_ids or _config_policy_id(bind, BUNDLE_NAME) is not None:
|
||||
return
|
||||
|
||||
config_policies = sa.table(
|
||||
'configurationpolicies',
|
||||
sa.column('name', sa.String),
|
||||
sa.column('description', sa.Text),
|
||||
sa.column('scopejson', sa.Text),
|
||||
sa.column('source', sa.String),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(config_policies, [{
|
||||
'name': BUNDLE_NAME,
|
||||
'description': 'Product-shipped watch-list for the shopfloor apps '
|
||||
'(eDNC, Universal Data Collector, MTConnect). Read-only.',
|
||||
'scopejson': '{"type": "true"}',
|
||||
'source': 'shipped',
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
config_policy_id = _config_policy_id(bind, BUNDLE_NAME)
|
||||
|
||||
actions = sa.table(
|
||||
'configurationpolicyactions',
|
||||
sa.column('configurationpolicyid', sa.Integer),
|
||||
sa.column('action', sa.String),
|
||||
sa.column('mode', sa.String),
|
||||
sa.column('desiredstate', sa.Text),
|
||||
sa.column('params', sa.Text),
|
||||
sa.column('intervalminutes', sa.Integer),
|
||||
sa.column('sortorder', sa.Integer),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(actions, [{
|
||||
'configurationpolicyid': config_policy_id,
|
||||
'action': f'monitor:{monitorid}',
|
||||
'mode': 'gate',
|
||||
'desiredstate': None,
|
||||
'params': None,
|
||||
'intervalminutes': None,
|
||||
'sortorder': index,
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
} for index, monitorid in enumerate(monitor_ids)])
|
||||
|
||||
business_policies = sa.table(
|
||||
'businesspolicies',
|
||||
sa.column('name', sa.String),
|
||||
sa.column('description', sa.Text),
|
||||
sa.column('priority', sa.Integer),
|
||||
sa.column('source', sa.String),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(business_policies, [{
|
||||
'name': BUNDLE_NAME,
|
||||
'description': 'Product-shipped Site Policy that applies the shopfloor '
|
||||
'app monitors to every endpoint. Read-only.',
|
||||
'priority': 100,
|
||||
'source': 'shipped',
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
business_policy_id = bind.execute(
|
||||
sa.text('SELECT businesspolicyid FROM businesspolicies '
|
||||
'WHERE name = :name'), {'name': BUNDLE_NAME}).scalar()
|
||||
|
||||
items = sa.table(
|
||||
'businesspolicyitems',
|
||||
sa.column('businesspolicyid', sa.Integer),
|
||||
sa.column('configurationpolicyid', sa.Integer),
|
||||
sa.column('sortorder', sa.Integer),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(items, [{
|
||||
'businesspolicyid': business_policy_id,
|
||||
'configurationpolicyid': config_policy_id,
|
||||
'sortorder': 0,
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
@@ -320,6 +320,21 @@ class EndpointsPlugin(BasePlugin):
|
||||
'agent, and configure what it reports.',
|
||||
'position': 27,
|
||||
},
|
||||
# Its own card rather than a section of the agent key page: the
|
||||
# audience differs. Key management is admin-only, while the script
|
||||
# library is reachable by any endpoints.runscript holder, and
|
||||
# burying it under an admin page would hide it from exactly the
|
||||
# operators the grant exists for.
|
||||
{
|
||||
'group': 'Endpoints',
|
||||
'to': '/settings/endpoints/scripts',
|
||||
'icon': 'terminal',
|
||||
'title': 'System Scripts',
|
||||
'description': 'PowerShell the agent can run on an endpoint. '
|
||||
'Runs as SYSTEM; used to trigger scheduled '
|
||||
'tasks already registered on the endpoint.',
|
||||
'position': 28,
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
|
||||
Reference in New Issue
Block a user