Separate shipped policies from site policies in the list
The list mixed both kinds of configuration policy into one table, so the only way to tell a shipped default from something the site had written was to read the source column. Split them into two tables under their own headings. The split is presentational only. Both tables render the same Edit and Delete buttons gated on the same iseditable(p) rule, so a shipped policy is not stranded behind a dead button the way it was when the buttons gated on source === 'local'. The spec counted exactly two disabled bindings, which the second table would have broken while the behaviour it was guarding stayed correct. It now counts Edit and Delete in pairs and asserts the two always agree, so adding a group does not fail a test about button gating.
This commit is contained in:
@@ -12,7 +12,21 @@
|
||||
has been configured.</p>
|
||||
</div>
|
||||
|
||||
<!-- Split into two cards rather than one list with a Source column.
|
||||
There is one shipped policy PER MONITOR, so shipped rows outnumber
|
||||
site-authored ones and a single sorted list buries the handful of
|
||||
policies the site actually wrote. The split also gives the shipped
|
||||
group somewhere to carry its warning, which a per-row badge cannot
|
||||
do. Both groups render the same row markup and the same editable
|
||||
rule - the separation is presentational, not a permission boundary. -->
|
||||
<div class="card">
|
||||
<h3>Shipped policies</h3>
|
||||
<p class="shipped-warning">
|
||||
These ship with the product as starting points - one per monitor. You
|
||||
can edit or delete them and your changes are kept, but an upgrade may
|
||||
add new rows here. Deleting one does not stop the monitor it refers to;
|
||||
it only removes this policy's opinion about it.
|
||||
</p>
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
@@ -27,7 +41,49 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in policies" :key="p.configurationpolicyid">
|
||||
<tr v-for="p in shippedPolicies" :key="p.configurationpolicyid">
|
||||
<td>{{ p.name }}</td>
|
||||
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
|
||||
<td>
|
||||
<span v-for="a in p.actions" :key="a.action" class="badge" style="margin-right:4px;">
|
||||
{{ actionLabel(a.action) }} ({{ a.mode }})
|
||||
</span>
|
||||
<span v-if="!p.actions || p.actions.length === 0" style="color: var(--text-light);">none</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-warning">{{ p.source }}</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<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="shippedPolicies.length === 0">
|
||||
<td colspan="5" style="text-align:center; color: var(--text-light);">No shipped policies</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Site policies</h3>
|
||||
<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>Actions in policy</th>
|
||||
<th>Source</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in localPolicies" :key="p.configurationpolicyid">
|
||||
<td>{{ p.name }}</td>
|
||||
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
|
||||
<td>
|
||||
@@ -46,7 +102,7 @@
|
||||
<button class="btn btn-danger btn-sm" :disabled="!iseditable(p)" @click="remove(p)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="policies.length === 0">
|
||||
<tr v-if="localPolicies.length === 0">
|
||||
<td colspan="5" style="text-align:center; color: var(--text-light);">No configuration policies</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -115,7 +171,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { computersApi, applicationsApi, endpointsApi } from '@/api'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
@@ -299,6 +355,17 @@ function iseditable(p) {
|
||||
return EDITABLE_SOURCES.includes(p.source)
|
||||
}
|
||||
|
||||
// Presentational split only. Anything that is not 'shipped' lands in the site
|
||||
// list - including 'autoapp', which is generated rather than hand-written but
|
||||
// is still this site's own row and carries its own badge there. Written as
|
||||
// "not shipped" rather than "=== local" so a future source value shows up in a
|
||||
// list instead of vanishing from both, which is the failure mode that made the
|
||||
// old Edit buttons dead.
|
||||
const shippedPolicies = computed(
|
||||
() => policies.value.filter(p => p.source === 'shipped'))
|
||||
const localPolicies = computed(
|
||||
() => policies.value.filter(p => p.source !== 'shipped'))
|
||||
|
||||
async function remove(p) {
|
||||
if (!confirm(`Delete configuration policy "${p.name}"?`)) return
|
||||
try {
|
||||
@@ -318,6 +385,14 @@ async function remove(p) {
|
||||
.feature-off-notice p {
|
||||
margin: 0.4rem 0 0 0;
|
||||
}
|
||||
/* Matches the feature-off notice rather than inventing a second warning
|
||||
treatment: same left rule, same warning colour. */
|
||||
.shipped-warning {
|
||||
border-left: 3px solid var(--warning);
|
||||
padding-left: 0.75rem;
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.modal-lg {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
@@ -1834,8 +1834,30 @@ async function confirmRestart() {
|
||||
const corporateTools = [
|
||||
{
|
||||
task: '\\ReportIP',
|
||||
label: 'Report IP',
|
||||
description: 'Re-runs the corporate IP reporting agent on this computer.',
|
||||
label: 'Report IP - Scheduled Task',
|
||||
description: 'Re-runs the corporate IP reporting agent on this computer '
|
||||
+ 'by starting its registered scheduled task as SYSTEM.',
|
||||
},
|
||||
// Same tool, different delivery. The entry above starts the endpoint's own
|
||||
// scheduled task; this one pushes a ShopDB-hosted script that reproduces
|
||||
// double-clicking the desktop icon, for machines where the task is missing
|
||||
// or not registered.
|
||||
//
|
||||
// It dispatches run_script rather than scheduledtask_run, so it carries a
|
||||
// `script` field instead of a task path. routes.py allows this ONE script
|
||||
// through on endpoints.corptools alone, matched by the stored script name in
|
||||
// CORPTOOLS_PUSHABLE_SCRIPTS - so this string is load-bearing and must stay
|
||||
// identical to the name the seeder writes. The name is sent rather than
|
||||
// script:<id> because the id is assigned at seed time and differs per
|
||||
// deployment; _resolve_run_script accepts either.
|
||||
{
|
||||
task: 'script:Report IP - Desktop Shortcut',
|
||||
script: 'Report IP - Desktop Shortcut',
|
||||
label: 'Report IP - Desktop Shortcut',
|
||||
description: 'Re-reports this computer\'s IP address by running the same '
|
||||
+ 'tool the desktop icon launches. Runs in the signed-in '
|
||||
+ 'user\'s session - fails if nobody is logged on. Use when '
|
||||
+ 'the scheduled task above is missing or fails.',
|
||||
},
|
||||
{
|
||||
task: '\\SFLD-ApplyDSCConfig',
|
||||
@@ -2274,17 +2296,32 @@ async function confirmRunTask(tool) {
|
||||
if (typed !== 'RUN') return
|
||||
} else if (!confirm(`Run "${tool.label}" on ${label}?\n\n`
|
||||
+ `${tool.description}\n\n`
|
||||
+ 'The task starts immediately on the endpoint.')) {
|
||||
+ (tool.script
|
||||
? 'The script is queued and runs at the next check-in.'
|
||||
: 'The task starts immediately on the endpoint.'))) {
|
||||
return
|
||||
}
|
||||
firingTask.value = tool.task
|
||||
try {
|
||||
await endpointsApi.enqueueCommand(
|
||||
computer.value.assetid, 'scheduledtask_run', tool.task)
|
||||
// "Started", not "finished": the agent reports success as soon as Task
|
||||
// Scheduler accepts the task, so this never means the work is done. Any
|
||||
// result the task produces comes back through telemetry, not this command.
|
||||
toast.success(`${tool.label} started on ${label}`)
|
||||
// Two dispatch shapes behind one list. Most entries start a task the
|
||||
// endpoint already approved; the managed-script entries deliver a body
|
||||
// ShopDB holds, which is a different action with a different server-side
|
||||
// check. Branching here rather than in the list keeps the card markup and
|
||||
// the confirm gate identical for both.
|
||||
if (tool.script) {
|
||||
await endpointsApi.enqueueCommand(
|
||||
computer.value.assetid, 'run_script', tool.script)
|
||||
// "Queued", not "started": run_script is collected on the agent's next
|
||||
// check-in, unlike scheduledtask_run which the endpoint accepts live.
|
||||
toast.success(`${tool.label} queued for ${label}`)
|
||||
} else {
|
||||
await endpointsApi.enqueueCommand(
|
||||
computer.value.assetid, 'scheduledtask_run', tool.task)
|
||||
// "Started", not "finished": the agent reports success as soon as Task
|
||||
// Scheduler accepts the task, so this never means the work is done. Any
|
||||
// result the task produces comes back through telemetry, not this command.
|
||||
toast.success(`${tool.label} started on ${label}`)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, `Failed to queue ${tool.label}`))
|
||||
} finally {
|
||||
|
||||
@@ -20,8 +20,26 @@ describe('the configuration policy buttons follow the API guard', () => {
|
||||
expect(view).not.toContain(":disabled=\"p.source !== 'local'\"")
|
||||
})
|
||||
|
||||
// Counted in pairs rather than pinned to a fixed number: the list renders
|
||||
// shipped and site policies as two separate tables, so there is one Edit and
|
||||
// one Delete per table. What matters is that the two buttons always agree
|
||||
// with each other and both defer to iseditable - not how many tables exist.
|
||||
it('gates edit and delete on the same rule', () => {
|
||||
const disabled = view.match(/:disabled="!iseditable\(p\)"/g) || []
|
||||
expect(disabled).toHaveLength(2)
|
||||
const edits = view.match(/@click="openModal\(p\)"/g) || []
|
||||
const deletes = view.match(/@click="remove\(p\)"/g) || []
|
||||
expect(disabled.length).toBe(edits.length + deletes.length)
|
||||
expect(edits.length).toBe(deletes.length)
|
||||
expect(edits.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// The split is presentational. Both groups must render the same buttons
|
||||
// under the same rule, so a shipped row is never stranded behind a dead
|
||||
// button the way it was when the buttons gated on source === 'local'.
|
||||
it('renders both policy groups from the same editable rule', () => {
|
||||
expect(view).toContain('shippedPolicies')
|
||||
expect(view).toContain('localPolicies')
|
||||
expect(view).toContain("p.source === 'shipped'")
|
||||
expect(view).toContain("p.source !== 'shipped'")
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user