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:
@@ -40,6 +40,23 @@ def _known_actions():
|
||||
return set(COMMAND_ALLOWLIST.keys())
|
||||
|
||||
|
||||
def _is_valid_action(name):
|
||||
"""True when an action name is either an allowlisted command or a monitor
|
||||
attachment (monitor:<id>) pointing at an existing monitor definition.
|
||||
|
||||
Monitor actions are intentionally kept out of the command allowlist so they
|
||||
are never enqueued as commands; they are only valid as policy attachments.
|
||||
"""
|
||||
if name in _known_actions():
|
||||
return True
|
||||
from plugins.endpoints.commands import monitor_id_from_action
|
||||
from plugins.endpoints.models import EndpointMonitor
|
||||
monitorid = monitor_id_from_action(name)
|
||||
if monitorid is None:
|
||||
return False
|
||||
return db.session.get(EndpointMonitor, monitorid) is not None
|
||||
|
||||
|
||||
def _configpolicy_dict(policy):
|
||||
data = policy.to_dict()
|
||||
data['actions'] = [a.to_dict() for a in policy.actions]
|
||||
@@ -89,11 +106,15 @@ def _apply_actions(policy, actions):
|
||||
|
||||
Returns an error_response on failure, else None.
|
||||
"""
|
||||
known = _known_actions()
|
||||
policy.actions.clear()
|
||||
# Flush the deletes before re-appending. The (configurationpolicyid, action)
|
||||
# unique constraint collides if SQLAlchemy orders a re-added action's INSERT
|
||||
# before the old row's DELETE in the same flush, which surfaces as a generic
|
||||
# "value already in use" IntegrityError on an otherwise valid edit.
|
||||
db.session.flush()
|
||||
for row in actions or []:
|
||||
name = (row.get('action') or '').strip()
|
||||
if name not in known:
|
||||
if not _is_valid_action(name):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'Unknown action: {name!r}')
|
||||
mode = (row.get('mode') or ACTION_MODE_GATE).strip()
|
||||
@@ -259,6 +280,10 @@ def get_business_policy(policy_id: int):
|
||||
def _apply_items(policy, items):
|
||||
"""Replace a business policy's configuration-policy items, validating each."""
|
||||
policy.items.clear()
|
||||
# Flush the deletes before re-appending so the (businesspolicyid,
|
||||
# configurationpolicyid) unique constraint does not collide with a re-added
|
||||
# item whose INSERT would otherwise race ahead of the old row's DELETE.
|
||||
db.session.flush()
|
||||
seen = set()
|
||||
for order, row in enumerate(items or []):
|
||||
cpid = row.get('configurationpolicyid')
|
||||
@@ -375,14 +400,19 @@ def delete_business_policy(policy_id: int):
|
||||
@computers_bp.route('/policies/actions', methods=['GET'])
|
||||
@require_permission('computers.view')
|
||||
def list_policy_actions():
|
||||
"""The allowlisted action names + labels a policy may reference."""
|
||||
"""The allowlisted action names + labels a policy may reference.
|
||||
|
||||
Returns command actions tagged kind='command'. Monitor attachments are not
|
||||
listed here: the UI builds that branch from the endpoints monitor catalog
|
||||
(endpointsApi.monitors) so newly created monitors appear without a reload.
|
||||
"""
|
||||
gate = _feature_gate()
|
||||
if gate:
|
||||
return gate
|
||||
from plugins.endpoints.commands import COMMAND_ALLOWLIST
|
||||
return success_response([
|
||||
{'action': name, 'label': meta.get('label'),
|
||||
'description': meta.get('description')}
|
||||
'description': meta.get('description'), 'kind': 'command'}
|
||||
for name, meta in COMMAND_ALLOWLIST.items()
|
||||
])
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -13,6 +13,7 @@ from .policy import (
|
||||
BusinessPolicy,
|
||||
BusinessPolicyItem,
|
||||
POLICY_SOURCE_LOCAL,
|
||||
POLICY_SOURCE_SHIPPED,
|
||||
ACTION_MODE_DESIRED,
|
||||
ACTION_MODE_GATE,
|
||||
)
|
||||
@@ -28,6 +29,7 @@ __all__ = [
|
||||
'BusinessPolicy',
|
||||
'BusinessPolicyItem',
|
||||
'POLICY_SOURCE_LOCAL',
|
||||
'POLICY_SOURCE_SHIPPED',
|
||||
'ACTION_MODE_DESIRED',
|
||||
'ACTION_MODE_GATE',
|
||||
]
|
||||
|
||||
@@ -27,6 +27,12 @@ from .jsontext import JSONText
|
||||
# owner. Kept a plain string, not an enum column, so adding a source is data.
|
||||
POLICY_SOURCE_LOCAL = 'local'
|
||||
|
||||
# Product-owned rows we ship (the shopfloor app monitor bundle). Same read-only
|
||||
# treatment as any non-local source: the CRUD API refuses to edit or delete
|
||||
# them, the seeder reconciles them. Distinct from 'local' so a site's own
|
||||
# policies are never confused with what the product shipped.
|
||||
POLICY_SOURCE_SHIPPED = 'shipped'
|
||||
|
||||
|
||||
class ConfigurationPolicy(BaseModel):
|
||||
"""A single reusable policy: a scope plus the actions it applies.
|
||||
|
||||
@@ -429,9 +429,9 @@ class ComputersPlugin(BasePlugin):
|
||||
},
|
||||
{
|
||||
'group': 'Computers',
|
||||
'to': '/settings/business-policies',
|
||||
'to': '/settings/site-policies',
|
||||
'icon': 'layers',
|
||||
'title': 'Business policies',
|
||||
'title': 'Site policies',
|
||||
'description': 'Ordered collections of configuration policies '
|
||||
'assigned by priority',
|
||||
'position': 28,
|
||||
@@ -483,7 +483,7 @@ class ComputersPlugin(BasePlugin):
|
||||
'valuetype': 'boolean',
|
||||
'category': 'computers',
|
||||
'description': 'Master switch for RMM-style Configuration and '
|
||||
'Business Policies. Off by default: models exist '
|
||||
'Site Policies. Off by default: models exist '
|
||||
'but the CRUD routes, WebUI, and endpoint '
|
||||
'enforcement stay inert so the whole feature can '
|
||||
'be rolled back without a schema change.',
|
||||
|
||||
@@ -166,3 +166,61 @@ def effective_policy_for(assetid):
|
||||
|
||||
business_policies = BusinessPolicy.query.filter_by(isactive=True).all()
|
||||
return build_effective_policy(computer, business_policies)
|
||||
|
||||
|
||||
def _monitor_action_ids(effective):
|
||||
"""Extract attached monitor ids from an EffectivePolicy's action names.
|
||||
|
||||
Monitor attachment rides the policy layer as an action named
|
||||
'monitor:<endpointmonitorid>' (see plugins/endpoints/commands.py). This
|
||||
reads those ids back out of a resolved policy so a caller can turn them into
|
||||
concrete monitor definitions.
|
||||
"""
|
||||
ids = set()
|
||||
for name in effective.allowed_actions():
|
||||
if isinstance(name, str) and name.startswith('monitor:'):
|
||||
raw = name[len('monitor:'):]
|
||||
try:
|
||||
ids.add(int(raw))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return ids
|
||||
|
||||
|
||||
def monitor_ids_for_asset(assetid):
|
||||
"""Set of endpointmonitorids attached to an endpoint by effective policy."""
|
||||
return _monitor_action_ids(effective_policy_for(assetid))
|
||||
|
||||
|
||||
def monitor_ids_for_pctype(computertypeid):
|
||||
"""Set of endpointmonitorids attached to a PC type by effective policy.
|
||||
|
||||
Folds active Business Policies against a synthetic context carrying only the
|
||||
PC type (no installed apps), which is what the agent runtime-fetch path needs
|
||||
when it resolves its monitor set by type rather than by a specific asset.
|
||||
Returns an empty set when the policy feature is off.
|
||||
"""
|
||||
from ..policyflag import policy_flag_enabled
|
||||
if not policy_flag_enabled():
|
||||
return set()
|
||||
|
||||
from ..models import BusinessPolicy
|
||||
|
||||
context = {'computertypeid': computertypeid, 'appids': set()}
|
||||
business_policies = BusinessPolicy.query.filter_by(isactive=True).all()
|
||||
resolved = {}
|
||||
for businesspolicyid, config_policy in _iter_matching_configpolicies(
|
||||
business_policies, context):
|
||||
for action_row in config_policy.actions:
|
||||
if not action_row.isactive:
|
||||
continue
|
||||
resolved.setdefault(action_row.action, True)
|
||||
ids = set()
|
||||
for name in resolved:
|
||||
if isinstance(name, str) and name.startswith('monitor:'):
|
||||
raw = name[len('monitor:'):]
|
||||
try:
|
||||
ids.add(int(raw))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return ids
|
||||
|
||||
Reference in New Issue
Block a user