Add configuration policy monitors and action picker for computers

Configuration policies need a way to attach process/service monitors so drift can be evaluated against a machine's desired state. This wires the policy resolver and routes to the shared monitor catalog and adds an action picker UI that can create process monitors with an optional launch path.
This commit is contained in:
benjamin.baker
2026-09-01 15:45:52 -04:00
parent a24429046c
commit d973ce6c26
9 changed files with 492 additions and 36 deletions

View File

@@ -70,8 +70,8 @@ export default [
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
},
{
path: 'settings/business-policies',
name: 'business-policies',
path: 'settings/site-policies',
name: 'site-policies',
component: () => import('./views/BusinessPoliciesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
}

View File

@@ -0,0 +1,265 @@
<template>
<div class="action-picker" @keydown.esc="closeDropdown">
<input
ref="inputEl"
type="text"
class="form-control"
:placeholder="placeholder"
v-model="query"
@focus="openDropdown"
@input="openDropdown"
/>
<div v-if="open" class="action-dropdown">
<!-- Command level: commands + a Monitor branch entry -->
<template v-if="level === 'command'">
<div
v-for="opt in filteredCommands"
:key="opt.action"
class="action-option"
@mousedown.prevent="selectCommand(opt)"
>
{{ opt.label || opt.action }}
</div>
<div
class="action-option action-branch"
@mousedown.prevent="enterMonitorLevel"
>
Monitor...
</div>
<div v-if="filteredCommands.length === 0" class="action-empty">No matching actions</div>
</template>
<!-- Monitor level: two create entries pinned on top, then existing monitors -->
<template v-else-if="level === 'monitor'">
<div class="action-option action-create" @mousedown.prevent="startCreate('process')">
&lt;Create Process Monitor&gt;
</div>
<div class="action-option action-create" @mousedown.prevent="startCreate('service')">
&lt;Create Service Monitor&gt;
</div>
<div class="action-divider"></div>
<div
v-for="m in filteredMonitors"
:key="m.endpointmonitorid"
class="action-option"
@mousedown.prevent="selectMonitor(m)"
>
{{ m.displayname }} <span class="monitor-kind">({{ m.kind }})</span>
</div>
<div v-if="filteredMonitors.length === 0" class="action-empty">No matching monitors</div>
</template>
<!-- Create level: inline text field to name the new process/service monitor -->
<template v-else>
<div class="action-create-form" @mousedown.prevent>
<label class="action-create-label">
{{ createkind === 'service' ? 'Service name to monitor' : 'Process name to monitor' }}
</label>
<input
ref="createInputEl"
type="text"
class="form-control"
:placeholder="createkind === 'service' ? 'e.g. Spooler' : 'e.g. agent.exe'"
v-model="createname"
@keydown.enter.prevent="submitCreate"
@keydown.esc.prevent="cancelCreate"
/>
<template v-if="createkind === 'process'">
<label class="action-create-label">
Launch path (optional)
</label>
<input
type="text"
class="form-control"
placeholder="e.g. C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
v-model="createlaunchpath"
@keydown.enter.prevent="submitCreate"
@keydown.esc.prevent="cancelCreate"
/>
</template>
<div class="action-create-actions">
<button type="button" class="btn btn-primary btn-sm" :disabled="!createname.trim() || creating" @mousedown.prevent="submitCreate">
Create
</button>
<button type="button" class="btn btn-secondary btn-sm" @mousedown.prevent="cancelCreate">
Cancel
</button>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { endpointsApi } from '@/api'
import { apiError } from '@/utils/apiError'
import { useToast } from '@/composables/toast'
const props = defineProps({
modelValue: { type: String, default: '' },
commandActions: { type: Array, default: () => [] },
monitors: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue', 'monitor-created'])
const toast = useToast()
const inputEl = ref(null)
const createInputEl = ref(null)
const open = ref(false)
// 'command' -> top level, 'monitor' -> monitor list, 'create' -> inline name form
const level = ref('command')
const query = ref('')
const createkind = ref('process')
const createname = ref('')
const createlaunchpath = ref('')
const creating = ref(false)
// Show the chosen action's friendly label in the input when not actively typing.
watch(() => props.modelValue, syncLabel, { immediate: true })
watch(() => props.monitors, () => { if (!open.value) syncLabel() })
function syncLabel() {
query.value = labelFor(props.modelValue)
}
function labelFor(action) {
if (!action) return ''
if (action.startsWith('monitor:')) {
const id = Number(action.slice('monitor:'.length))
const monitor = props.monitors.find(m => m.endpointmonitorid === id)
return monitor ? `Monitor: ${monitor.displayname} (${monitor.kind})` : action
}
const command = props.commandActions.find(c => c.action === action)
return command ? (command.label || command.action) : action
}
const placeholder = computed(() =>
level.value === 'monitor' ? 'Search monitors...' : 'Search actions...')
const filteredCommands = computed(() => {
const term = query.value.trim().toLowerCase()
if (!term) return props.commandActions
return props.commandActions.filter(c =>
(c.label || c.action).toLowerCase().includes(term))
})
const filteredMonitors = computed(() => {
const term = query.value.trim().toLowerCase()
if (!term) return props.monitors
return props.monitors.filter(m => m.displayname.toLowerCase().includes(term))
})
function openDropdown() {
open.value = true
}
function closeDropdown() {
open.value = false
level.value = 'command'
resetCreate()
syncLabel()
}
function enterMonitorLevel() {
level.value = 'monitor'
query.value = ''
inputEl.value?.focus()
}
function selectCommand(opt) {
emit('update:modelValue', opt.action)
open.value = false
level.value = 'command'
query.value = opt.label || opt.action
}
function selectMonitor(monitor) {
emit('update:modelValue', `monitor:${monitor.endpointmonitorid}`)
open.value = false
level.value = 'command'
query.value = `Monitor: ${monitor.displayname} (${monitor.kind})`
}
async function submitCreate() {
const displayname = createname.value.trim()
if (!displayname || creating.value) return
const kind = createkind.value
const payload = {
kind,
displayname,
processname: kind === 'process' ? displayname : '',
servicename: kind === 'service' ? displayname : '',
isactive: true
}
if (kind === 'process' && createlaunchpath.value.trim()) {
payload.launchpath = createlaunchpath.value.trim()
}
creating.value = true
try {
const response = await endpointsApi.monitors.create(payload)
const created = response.data.data
emit('monitor-created', created)
emit('update:modelValue', `monitor:${created.endpointmonitorid}`)
open.value = false
level.value = 'command'
resetCreate()
query.value = `Monitor: ${created.displayname} (${created.kind})`
} catch (err) {
toast.error(apiError(err, 'Failed to create monitor'))
} finally {
creating.value = false
}
}
function startCreate(kind) {
createkind.value = kind
createname.value = ''
createlaunchpath.value = ''
level.value = 'create'
nextTick(() => createInputEl.value?.focus())
}
function resetCreate() {
createname.value = ''
createlaunchpath.value = ''
creating.value = false
}
function cancelCreate() {
resetCreate()
level.value = 'monitor'
nextTick(() => inputEl.value?.focus())
}
</script>
<style scoped>
.action-picker { position: relative; flex: 1; min-width: 220px; }
.action-dropdown {
position: absolute;
z-index: 20;
left: 0;
right: 0;
margin-top: 2px;
max-height: 320px;
overflow-y: auto;
background: var(--bg-card-solid);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
}
.action-option { padding: 8px 12px; cursor: pointer; color: var(--text); }
.action-option:hover { background: var(--bg); }
.action-branch { font-style: italic; }
.action-create { font-weight: 600; color: var(--primary); }
.action-divider { height: 1px; background: var(--border); margin: 2px 0; }
.action-empty { padding: 6px 10px; color: var(--text-light); }
.monitor-kind { color: var(--text-light); }
.action-create-form { padding: 10px; display: flex; flex-direction: column; gap: 8px; }
.action-create-label { font-size: 12px; color: var(--text-light); }
.action-create-actions { display: flex; gap: 8px; }
</style>

View File

@@ -1,8 +1,8 @@
<template>
<div>
<div class="page-header">
<h2>Business Policies</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Business Policy</button>
<h2>Site Policies</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Site Policy</button>
</div>
<div class="card">
@@ -42,7 +42,7 @@
</td>
</tr>
<tr v-if="policies.length === 0">
<td colspan="6" style="text-align:center; color: var(--text-light);">No business policies</td>
<td colspan="6" style="text-align:center; color: var(--text-light);">No site policies</td>
</tr>
</tbody>
</table>
@@ -53,7 +53,7 @@
<div v-if="showModal" class="modal-overlay">
<div class="modal modal-lg">
<div class="modal-header">
<h3>{{ editing ? 'Edit Business Policy' : 'Add Business Policy' }}</h3>
<h3>{{ editing ? 'Edit Site Policy' : 'Add Site Policy' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
@@ -196,14 +196,14 @@ async function save() {
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save business policy')
error.value = apiError(err, 'Failed to save site policy')
} finally {
saving.value = false
}
}
async function remove(p) {
if (!confirm(`Delete business policy "${p.name}"?`)) return
if (!confirm(`Delete site policy "${p.name}"?`)) return
try {
await computersApi.policies.business.remove(p.businesspolicyid)
loadData()

View File

@@ -25,7 +25,7 @@
<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;">
{{ a.action }} ({{ a.mode }})
{{ actionLabel(a.action) }} ({{ a.mode }})
</span>
<span v-if="!p.actions || p.actions.length === 0" style="color: var(--text-light);">none</span>
</td>
@@ -74,17 +74,23 @@
<div class="form-group">
<label>Actions</label>
<div v-for="(a, index) in form.actions" :key="index" class="action-row">
<select v-model="a.action" class="form-control">
<option :value="''" disabled>Select action...</option>
<option v-for="opt in actionCatalog" :key="opt.action" :value="opt.action">
{{ opt.label || opt.action }}
</option>
</select>
<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>
@@ -103,10 +109,11 @@
<script setup>
import { ref, onMounted } from 'vue'
import { computersApi, applicationsApi } from '@/api'
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()
@@ -114,6 +121,7 @@ const policies = ref([])
const pctypes = ref([])
const apps = ref([])
const actionCatalog = ref([])
const monitors = ref([])
const loading = ref(true)
const showModal = ref(false)
@@ -132,21 +140,60 @@ onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const [pol, types, appList, actions] = await Promise.all([
// 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.list({ perpage: 200 }),
applicationsApi.listAll({ perpage: 200 }),
computersApi.policies.actions()
computersApi.policies.actions(),
endpointsApi.monitors.list()
])
policies.value = pol.data.data || []
pctypes.value = types.data.data || []
// applicationsApi.listAll resolves to a plain array (fetchAllPages), not an
// axios response, so read it directly. Reading .data on the array throws and
// rejects the whole Promise.all, blanking every dropdown.
apps.value = Array.isArray(appList) ? appList : (appList.data?.data || appList.data || [])
actionCatalog.value = actions.data.data || []
} catch (err) {
console.error('Error loading configuration policies:', err)
const failures = []
if (pol.status === 'fulfilled') {
policies.value = pol.value.data.data || []
} else {
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
}
@@ -177,6 +224,30 @@ 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 = ''
@@ -213,11 +284,35 @@ async function remove(p) {
</script>
<style scoped>
.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-color, #444);
border: 1px solid var(--border);
border-radius: 4px;
}
.action-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.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-text, #8a6d3b);
background: var(--warning-bg, rgba(240, 173, 78, 0.12));
border: 1px solid var(--warning-border, rgba(240, 173, 78, 0.4));
border-radius: 4px;
}
</style>