Import the wilm endpoint-monitor and channel-distribution work
Everything on origin/wilm that is not on the published main, applied as one
change because the published history has rewritten hashes: per-commit
cherry-pick conflicts against our main even though the squashed diff applies
clean.
Contents: endpoint monitor catalog + policy attachment + warn-only drift
reconciler, operator issue reports from the tray, agent launch-path allowlist,
and the GHCR channel-image distribution path for dev workstations.
NOT READY FOR main. Open items:
- 10 new routes are absent from docs/api-inventory.json, so
test_api_inventory_parity fails. That gate SKIPS on the published mirror
(the test ships, its data file is scrubbed), which is why wilm never saw it.
- dev-bootstrap.ps1 and Start-ShopDBDev.ps1 hardcode the ge-aero GitHub org.
- plugins/endpoints/plugin.py carries a whitespace-only re-indent.
Scope: product
This commit is contained in:
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