Three findings, all about a screen or a log saying something that is not so. Editing a PC wrote an audit entry for its named fields and nothing for the two changes that matter most when somebody investigates later: the primary IP address, which is how the machine is found, and the remote-access protocols and ports, which is how somebody connects to it. Both are on the same form, in the same request, and both were invisible afterwards - a PC that moved address had no history of moving, and a PC that gained a VNC listener had no record of gaining one. Both are recorded now, and re-sending the same value still records nothing, because an audit log full of non-events is one nobody reads. While proving that, a silent discard turned up in the same lines: both the create and the update path did `if ip_comtype:` around the IP write and did nothing at all when the 'IP' communication type row was missing. Somebody typed an address, saved, got a 200, and the address was gone. A missing reference row is a seeding gap, not a reason to drop what was typed, so it is created on demand. The PCs list rendered a 403 as "No computers found", which tells a person without the permission that the fleet is empty and sends them looking for the data rather than for the permission. It now says which permission is missing. The Site Policies and Configuration Policies pages rendered their empty table and an Add button while the policy feature flag was off - every route answers 404 "Policy feature is not enabled", so the button could never work. Both pages now say the feature is off and that the empty list is a consequence of that, not of nothing being configured. Nine tests. The audit entries verified by planting: remove either and two fail. Scope: product
339 lines
11 KiB
Vue
339 lines
11 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="p.source !== 'local'" @click="openModal(p)">Edit</button>
|
|
<button class="btn btn-danger btn-sm" :disabled="p.source !== 'local'" @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: 'true' }, 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') {
|
|
pctypes.value = types.value.data.data || []
|
|
} 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: 'true' },
|
|
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
|
|
}
|
|
}
|
|
|
|
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>
|