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:
@@ -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' }
|
||||
}
|
||||
|
||||
265
plugins/computers/frontend/views/ActionPicker.vue
Normal file
265
plugins/computers/frontend/views/ActionPicker.vue
Normal 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')">
|
||||
<Create Process Monitor>
|
||||
</div>
|
||||
<div class="action-option action-create" @mousedown.prevent="startCreate('service')">
|
||||
<Create Service Monitor>
|
||||
</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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user