The shipped Configuration Policies rendered with working-looking Edit and Delete buttons that did nothing. The API had already been widened to accept shipped rows, but the buttons still carried :disabled on source !== local, so the write was never sent and the change looked like it had never rolled. Gate both buttons on the set the API actually accepts. The shipped badge stays: it is provenance, not a lock, and it has to survive an edit so a site can still tell a product-supplied row from one of its own. Business Policies keep the local-only rule. Their API guard is unchanged and no shipped Site Policy exists now, so the UI already matches. Cover it with a spec, because the failure mode is a disabled attribute silently disagreeing with a server guard - nothing errors, the button just does nothing.
353 lines
12 KiB
Vue
353 lines
12 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>
|
|
|
|
<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>Actions in policy</th>
|
|
<th>Source</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="p in policies" :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="policies.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, 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)
|
|
}
|
|
|
|
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;
|
|
}
|
|
.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>
|