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.
428 lines
15 KiB
Vue
428 lines
15 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>Configuration Policies</h2>
|
|
<button v-if="!featureOff" class="btn btn-primary" @click="openModal()">+ Add Policy</button>
|
|
</div>
|
|
|
|
<div v-if="featureOff" class="card feature-off-notice">
|
|
<strong>Configuration policies are not enabled on this site.</strong>
|
|
<p>Nothing can be created here until the feature is turned on. The
|
|
list is empty because the feature is off, not because nothing
|
|
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">
|
|
<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 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>
|
|
<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" :class="p.source === 'local' ? 'badge-secondary' : '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="localPolicies.length === 0">
|
|
<td colspan="5" style="text-align:center; color: var(--text-light);">No configuration policies</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 Configuration Policy' : 'Add Configuration Policy' }}</h3>
|
|
</div>
|
|
<form @submit.prevent="save">
|
|
<div class="modal-body">
|
|
<div class="form-group">
|
|
<label>Name *</label>
|
|
<input v-model="form.name" type="text" class="form-control" required />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Description</label>
|
|
<textarea v-model="form.description" class="form-control" rows="2"></textarea>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Scope <span class="hint">(which endpoints this applies to)</span></label>
|
|
<div class="scope-builder">
|
|
<ScopeNodeEditor :node="form.scopejson" :pctypes="pctypes" :apps="apps" :removable="false" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Actions</label>
|
|
<div v-for="(a, index) in form.actions" :key="index" class="action-row">
|
|
<ActionPicker
|
|
v-model="a.action"
|
|
:command-actions="actionCatalog"
|
|
:monitors="monitors"
|
|
@monitor-created="onMonitorCreated"
|
|
/>
|
|
<select v-model="a.mode" class="form-control mode-select">
|
|
<option value="gate">Allow (gate)</option>
|
|
<option value="desired">Desired state</option>
|
|
</select>
|
|
<button type="button" class="btn btn-danger btn-sm" @click="form.actions.splice(index, 1)">Remove</button>
|
|
<p v-if="showDesiredWarning(a)" class="desired-warning">
|
|
Desired state keeps this app alive: on agent check-in the
|
|
server will try to START the process if a launch path is
|
|
mapped. Use gate to only report status with Start/Stop in the
|
|
Monitor UI.
|
|
</p>
|
|
</div>
|
|
<button type="button" class="btn btn-secondary btn-sm" @click="addAction">+ Add action</button>
|
|
</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, computed, onMounted } from 'vue'
|
|
import { computersApi, applicationsApi, endpointsApi } from '@/api'
|
|
import { useToast } from '@/composables/toast'
|
|
import { apiError } from '@/utils/apiError'
|
|
import ScopeNodeEditor from './ScopeNodeEditor.vue'
|
|
import ActionPicker from './ActionPicker.vue'
|
|
|
|
const toast = useToast()
|
|
|
|
const policies = ref([])
|
|
const pctypes = ref([])
|
|
const apps = ref([])
|
|
const actionCatalog = ref([])
|
|
const monitors = ref([])
|
|
const loading = ref(true)
|
|
const featureOff = ref(false)
|
|
|
|
const showModal = ref(false)
|
|
const editing = ref(null)
|
|
const saving = ref(false)
|
|
const error = ref('')
|
|
|
|
const form = ref(emptyForm())
|
|
|
|
function emptyForm() {
|
|
return { name: '', description: '', scopejson: { type: 'allpcs' }, actions: [] }
|
|
}
|
|
|
|
onMounted(() => loadData())
|
|
|
|
async function loadData() {
|
|
loading.value = true
|
|
try {
|
|
// allSettled, not all: one failing endpoint must not blank the others.
|
|
// A rejected Promise.all left PC types and apps empty when only monitors
|
|
// 500'd, which looked like data loss.
|
|
const [pol, types, appList, actions, monitorList] = await Promise.allSettled([
|
|
computersApi.policies.configuration.list(),
|
|
computersApi.types.listAll(),
|
|
applicationsApi.listAll(),
|
|
computersApi.policies.actions(),
|
|
endpointsApi.monitors.list()
|
|
])
|
|
|
|
const failures = []
|
|
|
|
if (pol.status === 'fulfilled') {
|
|
policies.value = pol.value.data.data || []
|
|
featureOff.value = false
|
|
} else {
|
|
// The API answers 404 'Policy feature is not enabled' when the flag is
|
|
// off. Without telling them apart the page rendered an empty table and
|
|
// an Add button for a feature that cannot accept one.
|
|
featureOff.value = pol.reason?.response?.status === 404
|
|
if (!featureOff.value) failures.push('policies')
|
|
console.error('Error loading policies:', pol.reason)
|
|
}
|
|
|
|
if (types.status === 'fulfilled') {
|
|
// Plain array, same as applications below. Reading .data.data threw
|
|
// inside this try, so apps, actions and monitors never got assigned.
|
|
pctypes.value = types.value || []
|
|
} else {
|
|
failures.push('PC types')
|
|
console.error('Error loading PC types:', types.reason)
|
|
}
|
|
|
|
if (appList.status === 'fulfilled') {
|
|
// applicationsApi.listAll resolves to a plain array (fetchAllPages), not
|
|
// an axios response, so read it directly.
|
|
const value = appList.value
|
|
apps.value = Array.isArray(value) ? value : (value.data?.data || value.data || [])
|
|
} else {
|
|
failures.push('applications')
|
|
console.error('Error loading applications:', appList.reason)
|
|
}
|
|
|
|
if (actions.status === 'fulfilled') {
|
|
actionCatalog.value = actions.value.data.data || []
|
|
} else {
|
|
failures.push('actions')
|
|
console.error('Error loading actions:', actions.reason)
|
|
}
|
|
|
|
if (monitorList.status === 'fulfilled') {
|
|
monitors.value = monitorList.value.data.data || []
|
|
} else {
|
|
failures.push('monitors')
|
|
console.error('Error loading monitors:', monitorList.reason)
|
|
}
|
|
|
|
if (failures.length) {
|
|
toast.error(`Failed to load: ${failures.join(', ')}. Some options may be unavailable.`)
|
|
}
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function openModal(item = null) {
|
|
editing.value = item
|
|
error.value = ''
|
|
if (item) {
|
|
form.value = {
|
|
name: item.name || '',
|
|
description: item.description || '',
|
|
scopejson: item.scopejson ? JSON.parse(JSON.stringify(item.scopejson)) : { type: 'allpcs' },
|
|
actions: (item.actions || []).map(a => ({
|
|
action: a.action, mode: a.mode || 'gate',
|
|
desiredstate: a.desiredstate, params: a.params, intervalminutes: a.intervalminutes
|
|
}))
|
|
}
|
|
} else {
|
|
form.value = emptyForm()
|
|
}
|
|
showModal.value = true
|
|
}
|
|
|
|
function closeModal() { showModal.value = false; editing.value = null }
|
|
|
|
function addAction() {
|
|
form.value.actions.push({ action: '', mode: 'gate' })
|
|
}
|
|
|
|
function onMonitorCreated(monitor) {
|
|
// Add the new definition to the local catalog so its label resolves and it
|
|
// is searchable on other action rows without a full reload.
|
|
if (monitor && !monitors.value.some(m => m.endpointmonitorid === monitor.endpointmonitorid)) {
|
|
monitors.value.push(monitor)
|
|
}
|
|
}
|
|
|
|
function actionLabel(action) {
|
|
if (!action) return action
|
|
if (action.startsWith('monitor:')) {
|
|
const id = Number(action.slice('monitor:'.length))
|
|
const monitor = monitors.value.find(m => m.endpointmonitorid === id)
|
|
return monitor ? `Monitor: ${monitor.displayname}` : action
|
|
}
|
|
const command = actionCatalog.value.find(c => c.action === action)
|
|
return command ? (command.label || command.action) : action
|
|
}
|
|
|
|
function showDesiredWarning(action) {
|
|
return !!action && action.mode === 'desired'
|
|
&& !!action.action && action.action.startsWith('monitor:')
|
|
}
|
|
|
|
async function save() {
|
|
saving.value = true
|
|
error.value = ''
|
|
try {
|
|
const payload = {
|
|
name: form.value.name,
|
|
description: form.value.description,
|
|
scopejson: form.value.scopejson,
|
|
actions: form.value.actions
|
|
}
|
|
if (editing.value) {
|
|
await computersApi.policies.configuration.update(editing.value.configurationpolicyid, payload)
|
|
} else {
|
|
await computersApi.policies.configuration.create(payload)
|
|
}
|
|
closeModal()
|
|
loadData()
|
|
} catch (err) {
|
|
error.value = apiError(err, 'Failed to save policy')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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 {
|
|
await computersApi.policies.configuration.remove(p.configurationpolicyid)
|
|
loadData()
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Failed to delete'))
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.feature-off-notice {
|
|
border-left: 3px solid var(--warning);
|
|
margin-bottom: 1rem;
|
|
}
|
|
.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;
|
|
}
|
|
.modal-lg .modal-body {
|
|
max-height: 70vh;
|
|
overflow-y: auto;
|
|
}
|
|
.modal-lg :deep(.form-group) {
|
|
margin-bottom: 1.1rem;
|
|
}
|
|
.modal-lg :deep(.form-control) {
|
|
font-size: 14px;
|
|
padding: 0.55rem 0.7rem;
|
|
}
|
|
.scope-builder {
|
|
padding: 10px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
}
|
|
.action-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 6px; }
|
|
.mode-select { max-width: 160px; }
|
|
.desired-warning {
|
|
flex-basis: 100%;
|
|
margin: 2px 0 0;
|
|
padding: 6px 8px;
|
|
font-size: 0.85em;
|
|
color: var(--warning-dark);
|
|
background: var(--bg);
|
|
border: 1px solid var(--warning);
|
|
border-radius: 4px;
|
|
}
|
|
</style>
|