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:
@@ -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.
|
||||
|
||||
@@ -559,9 +559,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,
|
||||
@@ -633,7 +633,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
|
||||
|
||||
@@ -5,5 +5,7 @@ from .routes import endpoints_bp
|
||||
from . import adoption # noqa: F401
|
||||
# Attaches the site agent generator packaging routes to endpoints_bp.
|
||||
from . import siteagent # noqa: F401
|
||||
# Attaches the monitor catalog CRUD and resolve routes to endpoints_bp.
|
||||
from . import monitors # noqa: F401
|
||||
|
||||
__all__ = ['endpoints_bp']
|
||||
|
||||
204
plugins/endpoints/api/monitors.py
Normal file
204
plugins/endpoints/api/monitors.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""Endpoint monitor definitions API (the monitor catalog CRUD + resolver).
|
||||
|
||||
A monitor definition is the reusable "what" an operator builds on the Settings
|
||||
page: a named process or service with a bespoke display label and optional
|
||||
launch/collector paths. Attaching a monitor to a PC type is a site-policy
|
||||
concern (see plugins/computers policies); this surface owns the definitions
|
||||
themselves plus the resolve-by-pctype read the agent uses at runtime.
|
||||
|
||||
Shipped rows (source='shipped') are product-owned: readable by everyone with
|
||||
the view permission but not editable or deletable through this API.
|
||||
|
||||
The resolve-by-pctype read returns the monitor set a deployed agent should watch
|
||||
for its PC type: the shipped defaults for 'Shopfloor PC' unioned with whatever
|
||||
active site policies attach to that type. Shipped monitors are hardcoded to
|
||||
'Shopfloor PC' as a proof of concept until they are authored through the policy
|
||||
route like any user-built monitor.
|
||||
"""
|
||||
|
||||
from flask import request
|
||||
from flask_jwt_extended import current_user
|
||||
|
||||
from shopdb.api import (
|
||||
db, success_response, error_response, ErrorCodes, require_permission,
|
||||
require_role,
|
||||
)
|
||||
|
||||
from ..models import EndpointMonitor
|
||||
from .routes import endpoints_bp
|
||||
|
||||
|
||||
# Shipped monitors default to this PC type until they are authored as policies.
|
||||
SHIPPED_DEFAULT_PCTYPE = 'Shopfloor PC'
|
||||
|
||||
_VALID_KINDS = ('process', 'service')
|
||||
|
||||
|
||||
def _validate_monitor_payload(body, kind):
|
||||
"""Return an error message for an invalid create/update body, or None."""
|
||||
displayname = (body.get('displayname') or '').strip()
|
||||
if not displayname:
|
||||
return 'displayname is required'
|
||||
if kind not in _VALID_KINDS:
|
||||
return "kind must be 'process' or 'service'"
|
||||
if kind == 'process' and not (body.get('processname') or '').strip():
|
||||
return 'processname is required for a process monitor'
|
||||
if kind == 'service' and not (body.get('servicename') or '').strip():
|
||||
return 'servicename is required for a service monitor'
|
||||
return None
|
||||
|
||||
|
||||
@endpoints_bp.route('/monitors', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def list_monitors():
|
||||
"""List monitor definitions (shipped and local)."""
|
||||
monitors = EndpointMonitor.query.order_by(
|
||||
EndpointMonitor.source.desc(),
|
||||
EndpointMonitor.displayname.asc()).all()
|
||||
return success_response([monitor.to_dict() for monitor in monitors])
|
||||
|
||||
|
||||
@endpoints_bp.route('/monitors', methods=['POST'])
|
||||
@require_role('admin')
|
||||
def create_monitor():
|
||||
"""Create a user-defined (local) monitor definition."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
kind = (body.get('kind') or '').strip().lower()
|
||||
problem = _validate_monitor_payload(body, kind)
|
||||
if problem:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, problem, http_code=400)
|
||||
|
||||
monitor = EndpointMonitor(
|
||||
kind=kind,
|
||||
displayname=(body.get('displayname') or '').strip(),
|
||||
processname=(body.get('processname') or '').strip() or None,
|
||||
servicename=(body.get('servicename') or '').strip() or None,
|
||||
launchpath=(body.get('launchpath') or '').strip() or None,
|
||||
datacollectorpath=(body.get('datacollectorpath') or '').strip() or None,
|
||||
logcollectorpath=(body.get('logcollectorpath') or '').strip() or None,
|
||||
source='local',
|
||||
isactive=bool(body.get('isactive', True)),
|
||||
)
|
||||
db.session.add(monitor)
|
||||
db.session.commit()
|
||||
return success_response(monitor.to_dict(), message='Monitor created',
|
||||
http_code=201)
|
||||
|
||||
|
||||
@endpoints_bp.route('/monitors/<int:monitor_id>', methods=['PUT'])
|
||||
@require_role('admin')
|
||||
def update_monitor(monitor_id):
|
||||
"""Update a local monitor definition. Shipped rows are read-only."""
|
||||
monitor = db.session.get(EndpointMonitor, monitor_id)
|
||||
if monitor is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Monitor not found',
|
||||
http_code=404)
|
||||
if monitor.source != 'local':
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'Shipped monitors cannot be edited', http_code=403)
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
kind = (body.get('kind') or monitor.kind or '').strip().lower()
|
||||
problem = _validate_monitor_payload(
|
||||
{
|
||||
'displayname': body.get('displayname', monitor.displayname),
|
||||
'processname': body.get('processname', monitor.processname),
|
||||
'servicename': body.get('servicename', monitor.servicename),
|
||||
},
|
||||
kind,
|
||||
)
|
||||
if problem:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, problem, http_code=400)
|
||||
|
||||
monitor.kind = kind
|
||||
monitor.displayname = (body.get('displayname', monitor.displayname) or '').strip()
|
||||
monitor.processname = (body.get('processname', monitor.processname) or '').strip() or None
|
||||
monitor.servicename = (body.get('servicename', monitor.servicename) or '').strip() or None
|
||||
monitor.launchpath = (body.get('launchpath', monitor.launchpath) or '').strip() or None
|
||||
monitor.datacollectorpath = (body.get('datacollectorpath', monitor.datacollectorpath) or '').strip() or None
|
||||
monitor.logcollectorpath = (body.get('logcollectorpath', monitor.logcollectorpath) or '').strip() or None
|
||||
if 'isactive' in body:
|
||||
monitor.isactive = bool(body.get('isactive'))
|
||||
db.session.commit()
|
||||
return success_response(monitor.to_dict(), message='Monitor updated')
|
||||
|
||||
|
||||
@endpoints_bp.route('/monitors/<int:monitor_id>', methods=['DELETE'])
|
||||
@require_role('admin')
|
||||
def delete_monitor(monitor_id):
|
||||
"""Delete a local monitor definition. Shipped rows are protected."""
|
||||
monitor = db.session.get(EndpointMonitor, monitor_id)
|
||||
if monitor is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Monitor not found',
|
||||
http_code=404)
|
||||
if monitor.source != 'local':
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'Shipped monitors cannot be deleted', http_code=403)
|
||||
db.session.delete(monitor)
|
||||
db.session.commit()
|
||||
return success_response(None, message='Monitor deleted')
|
||||
|
||||
|
||||
def _resolve_pctype_monitors(pctype):
|
||||
"""Monitor definitions a PC type should watch: shipped defaults + policy.
|
||||
|
||||
Shipped monitors are attached to SHIPPED_DEFAULT_PCTYPE as a code rule;
|
||||
policy-attached monitors come from the computers policy resolver by PC type.
|
||||
Returns a list of EndpointMonitor rows (active only), de-duped by id.
|
||||
"""
|
||||
selected = {}
|
||||
|
||||
if pctype == SHIPPED_DEFAULT_PCTYPE:
|
||||
for monitor in EndpointMonitor.query.filter_by(
|
||||
source='shipped', isactive=True).all():
|
||||
selected[monitor.endpointmonitorid] = monitor
|
||||
|
||||
computertypeid = _computertypeid_for(pctype)
|
||||
if computertypeid is not None:
|
||||
policy_ids = _policy_monitor_ids(computertypeid)
|
||||
if policy_ids:
|
||||
for monitor in EndpointMonitor.query.filter(
|
||||
EndpointMonitor.endpointmonitorid.in_(policy_ids),
|
||||
EndpointMonitor.isactive.is_(True)).all():
|
||||
selected[monitor.endpointmonitorid] = monitor
|
||||
|
||||
return list(selected.values())
|
||||
|
||||
|
||||
def _computertypeid_for(pctype):
|
||||
"""Resolve a PC type name to its computertypeid, or None."""
|
||||
try:
|
||||
from plugins.computers.models import ComputerType
|
||||
except Exception:
|
||||
return None
|
||||
row = ComputerType.query.filter_by(computertype=pctype).first()
|
||||
return row.computertypeid if row else None
|
||||
|
||||
|
||||
def _policy_monitor_ids(computertypeid):
|
||||
"""Monitor ids attached to a PC type by effective policy, or empty set."""
|
||||
try:
|
||||
from plugins.computers.services.policyresolver import monitor_ids_for_pctype
|
||||
except Exception:
|
||||
return set()
|
||||
return monitor_ids_for_pctype(computertypeid)
|
||||
|
||||
|
||||
@endpoints_bp.route('/monitors/resolve', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def resolve_monitors():
|
||||
"""Resolve the monitor set for a PC type (agent runtime fetch).
|
||||
|
||||
Query param pctype names the computer type. Returns the definitions the
|
||||
agent should watch: shipped defaults for 'Shopfloor PC' plus any monitors
|
||||
active site policies attach to that type.
|
||||
"""
|
||||
pctype = (request.args.get('pctype') or '').strip()
|
||||
if not pctype:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'pctype is required', http_code=400)
|
||||
monitors = _resolve_pctype_monitors(pctype)
|
||||
return success_response({
|
||||
'pctype': pctype,
|
||||
'monitors': [monitor.to_dict() for monitor in monitors],
|
||||
})
|
||||
@@ -25,7 +25,10 @@ from shopdb.api import (
|
||||
Application, AppVersion, Script,
|
||||
)
|
||||
|
||||
from ..models import EndpointState, EndpointEvent, EndpointCommand, EndpointApplication
|
||||
from ..models import (
|
||||
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
|
||||
OperatorReport,
|
||||
)
|
||||
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for
|
||||
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
|
||||
|
||||
@@ -87,7 +90,8 @@ def _format_uptime(seconds):
|
||||
# Endpoint state
|
||||
# =============================================================================
|
||||
|
||||
@endpoints_bp.route('', methods=['GET'])
|
||||
@endpoints_bp.route('', methods=['GET'], strict_slashes=False)
|
||||
@endpoints_bp.route('/', methods=['GET'], strict_slashes=False)
|
||||
@require_permission('endpoints.fleet')
|
||||
def list_endpoints():
|
||||
"""List reported endpoint states.
|
||||
@@ -396,6 +400,24 @@ def list_endpoint_events(asset_id: int):
|
||||
# Asset panels (ADR-010) - shaped for the generic PluginAssetPanels renderer
|
||||
# =============================================================================
|
||||
|
||||
def _drift_for_state(state):
|
||||
"""Parse the cached warn-only reconciler drift list off an EndpointState.
|
||||
|
||||
driftjson is written on every check-in by the endpoints ingest: the monitors
|
||||
site policy expects running that the latest snapshot did not report running.
|
||||
Null means never computed; an empty list means computed with no drift.
|
||||
Tolerant of a malformed blob so a bad cache never breaks the panel.
|
||||
"""
|
||||
raw = getattr(state, 'driftjson', None) if state else None
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/status', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_status(asset_id: int):
|
||||
@@ -425,6 +447,19 @@ def panel_status(asset_id: int):
|
||||
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None),
|
||||
]
|
||||
fields = [f for f in fields if f['value'] not in (None, '')]
|
||||
|
||||
# Warn-only reconciler summary: monitors site policy expects to be running
|
||||
# that the last check-in did not report as running. Surfaced as a status
|
||||
# field so an operator sees the drift on the panel; nothing is auto-started.
|
||||
drift = _drift_for_state(state)
|
||||
if drift:
|
||||
names = ', '.join(entry.get('displayname') or entry.get('name')
|
||||
for entry in drift)
|
||||
fields.append(field(
|
||||
'Needs attention',
|
||||
'{} monitor(s) expected running but not reported: {}'.format(
|
||||
len(drift), names)))
|
||||
|
||||
return success_response({'fields': fields})
|
||||
|
||||
|
||||
@@ -446,28 +481,100 @@ def panel_telemetry_meta(asset_id: int):
|
||||
return success_response({'lastreportedat': stamp})
|
||||
|
||||
|
||||
def _pctype_for_asset(asset_id):
|
||||
"""Resolve an asset's PC type name, or None.
|
||||
|
||||
Endpoints stays optional-on-computers: a missing plugin, a non-computer
|
||||
asset, or an unset type all resolve to None so the caller falls back to
|
||||
reported-only behaviour.
|
||||
"""
|
||||
try:
|
||||
from plugins.computers.models import Computer, ComputerType
|
||||
except Exception:
|
||||
return None
|
||||
row = (db.session.query(ComputerType.computertype)
|
||||
.join(Computer,
|
||||
Computer.computertypeid == ComputerType.computertypeid)
|
||||
.filter(Computer.assetid == asset_id)
|
||||
.first())
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def _resolved_monitors_for_asset(asset_id):
|
||||
"""Monitor definitions attached to this asset's PC type by policy/shipped.
|
||||
|
||||
Reuses the endpoints monitor resolver (shipped defaults plus policy-attached
|
||||
monitors). Returns a list of EndpointMonitor rows, or an empty list when the
|
||||
PC type is unknown or nothing resolves.
|
||||
"""
|
||||
pctype = _pctype_for_asset(asset_id)
|
||||
if not pctype:
|
||||
return []
|
||||
try:
|
||||
from .monitors import _resolve_pctype_monitors
|
||||
return _resolve_pctype_monitors(pctype) or []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _row_action_dict(app, kind, name, running):
|
||||
"""Build the inline Start/Stop action map for a services/processes row."""
|
||||
targets = row_actions_for(app, kind, name)
|
||||
if not targets:
|
||||
return None
|
||||
return {
|
||||
'running': running,
|
||||
'start': targets['start'],
|
||||
'stop': targets['stop'],
|
||||
'status': targets['status'],
|
||||
'target': targets['target'],
|
||||
'targetkind': targets['targetkind'],
|
||||
}
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/services', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_services(asset_id: int):
|
||||
"""List panel: monitored services/processes with a run-state badge per row.
|
||||
|
||||
Reshapes the latest servicesjson snapshot into rows the list renderer maps
|
||||
to name + status badge. Tolerant of a missing or unexpected snapshot shape:
|
||||
an unknown state renders as an Unknown badge rather than failing.
|
||||
to name + status badge, then MERGES the monitors that site policy (and the
|
||||
shipped defaults) attach to this asset's PC type. A policy-attached monitor
|
||||
the agent has not yet reported surfaces as an Unknown row with a Start
|
||||
control, so an operator sees the intent immediately rather than waiting for
|
||||
the next agent snapshot. Tolerant of a missing snapshot or plugin: an
|
||||
unknown state renders as an Unknown badge rather than failing.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
if not state or not state.servicesjson:
|
||||
return success_response([])
|
||||
|
||||
maycommand = caller_holds('endpoints.command')
|
||||
|
||||
try:
|
||||
services = json.loads(state.servicesjson)
|
||||
except (TypeError, ValueError):
|
||||
return success_response([])
|
||||
services = []
|
||||
if state and state.servicesjson:
|
||||
try:
|
||||
services = json.loads(state.servicesjson) or []
|
||||
except (TypeError, ValueError):
|
||||
services = []
|
||||
|
||||
rows = []
|
||||
for entry in (services or []):
|
||||
# Reported identities, keyed (kind, lowercased name), so a policy monitor
|
||||
# the agent already reports is not duplicated as a synthetic row.
|
||||
reported = set()
|
||||
# Warn-only reconciler drift, keyed (kind, lowercased name), so a row that is
|
||||
# expected running but not reported running can be flagged for the operator.
|
||||
# kind may be None on an older snapshot, so match on name too.
|
||||
drift_keys = set()
|
||||
for entry in _drift_for_state(state):
|
||||
name = (entry.get('name') or '').strip().lower()
|
||||
if not name:
|
||||
continue
|
||||
drift_keys.add(((entry.get('kind') or '').lower() or None, name))
|
||||
drift_keys.add((None, name))
|
||||
|
||||
def _is_drift(kind, name):
|
||||
key = (name or '').strip().lower()
|
||||
return (kind, key) in drift_keys or (None, key) in drift_keys
|
||||
|
||||
for entry in services:
|
||||
if isinstance(entry, dict):
|
||||
name = entry.get('name') or entry.get('service') or 'Unknown'
|
||||
raw = (entry.get('state') or entry.get('status') or '').strip().lower()
|
||||
@@ -476,9 +583,10 @@ def panel_services(asset_id: int):
|
||||
else:
|
||||
name, raw, app, kind = str(entry), '', None, None
|
||||
running = _service_running(entry)
|
||||
normkind = (kind or '').lower() or None
|
||||
row = {
|
||||
'name': name,
|
||||
'kind': (kind or '').lower() or None,
|
||||
'kind': normkind,
|
||||
'statelabel': 'Running' if running else (raw.title() or 'Unknown'),
|
||||
# Amber for paused and the four Windows transitional states. A
|
||||
# service stuck in Start Pending is failing to start on a retry
|
||||
@@ -493,17 +601,42 @@ def panel_services(asset_id: int):
|
||||
# emission never consulted the caller, so a read-only viewer was shown
|
||||
# controls on a shop-floor PC that 403 on click - wrong signal, and it
|
||||
# gets worse the moment guests can read this panel.
|
||||
targets = row_actions_for(app, kind, name) if maycommand else None
|
||||
if targets:
|
||||
row['action'] = {
|
||||
'running': running,
|
||||
'start': targets['start'],
|
||||
'stop': targets['stop'],
|
||||
'status': targets['status'],
|
||||
'target': targets['target'],
|
||||
'targetkind': targets['targetkind'],
|
||||
}
|
||||
action = _row_action_dict(app, kind, name, running) if maycommand else None
|
||||
if action:
|
||||
row['action'] = action
|
||||
if not running and _is_drift(normkind, name):
|
||||
row['drift'] = True
|
||||
rows.append(row)
|
||||
reported.add((normkind, name.strip().lower()))
|
||||
|
||||
# Merge policy/shipped monitors this PC type should watch. A monitor already
|
||||
# reported by the agent stays as its live row; one not yet seen is appended
|
||||
# as an Unknown row so the operator sees the intended coverage and can Start
|
||||
# it. The monitor's displayname is the operator label (e.g. 'msedge.exe'
|
||||
# -> 'Edge'); its process/service name is the wire target for actions.
|
||||
for monitor in _resolved_monitors_for_asset(asset_id):
|
||||
kind = (monitor.kind or '').lower() or None
|
||||
osname = (monitor.processname if kind == 'process'
|
||||
else monitor.servicename)
|
||||
osname = (osname or '').strip()
|
||||
if not osname:
|
||||
continue
|
||||
if (kind, osname.lower()) in reported:
|
||||
continue
|
||||
row = {
|
||||
'name': monitor.displayname or osname,
|
||||
'kind': kind,
|
||||
'statelabel': 'Unknown',
|
||||
'statecolor': 'gray',
|
||||
}
|
||||
action = _row_action_dict(None, kind, osname, False) if maycommand else None
|
||||
if action:
|
||||
row['action'] = action
|
||||
if _is_drift(kind, osname):
|
||||
row['drift'] = True
|
||||
rows.append(row)
|
||||
reported.add((kind, osname.lower()))
|
||||
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
@@ -1335,6 +1468,64 @@ def enqueue_endpoint_command(asset_id: int):
|
||||
message='Command queued', http_code=201)
|
||||
|
||||
|
||||
@endpoints_bp.route('/monitors/resolved', methods=['GET'])
|
||||
def resolved_monitors_for_agent():
|
||||
"""Agent watch list: the monitors this endpoint should report on.
|
||||
|
||||
Collector-token auth (not JWT), same as the command channel. The agent
|
||||
calls this each telemetry cycle with its hostname and receives the process
|
||||
and service monitors that site policy (and the shipped defaults) attach to
|
||||
this endpoint's PC type, so a policy-attached monitor is enumerated and
|
||||
reported without editing appsettings.json on the box. The shape mirrors the
|
||||
agent's MonitorConfig (processes[]/services[] of {name, app}) so the agent
|
||||
can bind it directly: name is the wire identity (process image or service
|
||||
key), app is the operator label. An unknown hostname or a PC type with no
|
||||
monitors returns empty lists, which the agent merges as "watch nothing extra".
|
||||
"""
|
||||
error = _collector_auth_or_error()
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
hostname = (request.args.get('hostname') or '').strip()
|
||||
if not hostname:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'hostname is required')
|
||||
|
||||
asset_id = _resolve_asset_for_hostname(hostname)
|
||||
if asset_id is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Unknown hostname: {hostname!r}', http_code=404)
|
||||
|
||||
processes = []
|
||||
services = []
|
||||
for monitor in _resolved_monitors_for_asset(asset_id):
|
||||
kind = (monitor.kind or '').lower()
|
||||
if kind == 'process':
|
||||
wirename = (monitor.processname or '').strip()
|
||||
bucket = processes
|
||||
elif kind == 'service':
|
||||
wirename = (monitor.servicename or '').strip()
|
||||
bucket = services
|
||||
else:
|
||||
continue
|
||||
if not wirename:
|
||||
continue
|
||||
entry = {
|
||||
'name': wirename,
|
||||
'app': (monitor.displayname or wirename),
|
||||
}
|
||||
# Only process monitors carry a launch path, and only when set. The
|
||||
# agent never trusts it blindly: it validates the path against an
|
||||
# approved-root allowlist before it becomes launchable (fail closed).
|
||||
if kind == 'process':
|
||||
launchpath = (monitor.launchpath or '').strip()
|
||||
if launchpath:
|
||||
entry['launchpath'] = launchpath
|
||||
bucket.append(entry)
|
||||
|
||||
return success_response({'processes': processes, 'services': services})
|
||||
|
||||
|
||||
@endpoints_bp.route('/commands/next', methods=['GET'])
|
||||
def claim_next_command():
|
||||
"""Agent long-poll: claim the next pending command for a hostname.
|
||||
@@ -1578,3 +1769,174 @@ def download_script_body(script_id: int):
|
||||
return send_upload(script_dir, script.filename, as_attachment=True)
|
||||
|
||||
|
||||
# Operator issue reports (tray screenshot + free-text notes)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Retention groundwork: how many days an operator report (and its screenshot
|
||||
# file) is kept before the purge sweep removes it. Read from the Setting store
|
||||
# so Agent Configuration can expose a toggle later without a code change; the
|
||||
# default is 7 days. A value of 0 or less means "keep forever" (purge disabled).
|
||||
OPERATOR_REPORT_RETENTION_SETTING = 'endpoints.operatorreport.retentiondays'
|
||||
OPERATOR_REPORT_RETENTION_DEFAULT_DAYS = 7
|
||||
|
||||
# Screenshots are small PNGs from a virtual-screen grab. Cap the upload so a
|
||||
# malformed or hostile agent cannot fill the disk with one request.
|
||||
OPERATOR_REPORT_MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def _operator_report_dir():
|
||||
return os.path.join(current_app.instance_path, 'operatorreports')
|
||||
|
||||
|
||||
def _operator_report_retention_days():
|
||||
"""Configured retention window in whole days (default 7, <=0 disables)."""
|
||||
raw = Setting.get(OPERATOR_REPORT_RETENTION_SETTING)
|
||||
if raw is None:
|
||||
return OPERATOR_REPORT_RETENTION_DEFAULT_DAYS
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return OPERATOR_REPORT_RETENTION_DEFAULT_DAYS
|
||||
|
||||
|
||||
def _purge_expired_operator_reports():
|
||||
"""Delete operator reports older than the retention window, files and all.
|
||||
|
||||
Best-effort and idempotent: called opportunistically on upload so the feed
|
||||
stays bounded without a scheduler. A retention of <=0 disables the sweep.
|
||||
"""
|
||||
days = _operator_report_retention_days()
|
||||
if days <= 0:
|
||||
return
|
||||
|
||||
cutoff = _naive_utcnow() - timedelta(days=days)
|
||||
stale = OperatorReport.query.filter(OperatorReport.createdat < cutoff).all()
|
||||
if not stale:
|
||||
return
|
||||
|
||||
report_dir = _operator_report_dir()
|
||||
for report in stale:
|
||||
if report.imagefilename:
|
||||
path = os.path.join(report_dir, report.imagefilename)
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
db.session.delete(report)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@endpoints_bp.route('/operatorreports', methods=['POST'])
|
||||
def upload_operator_report():
|
||||
"""Agent uploads one operator issue report (collector-token auth).
|
||||
|
||||
The tray captures the operator's free-text note and an optional screenshot,
|
||||
then hands them to the service, which posts here as multipart/form-data on
|
||||
the same endpoints collector token used for telemetry. The reported hostname
|
||||
resolves to an asset; an unresolved hostname is a validation error. The
|
||||
screenshot bytes are written to the operator-reports uploads dir and the row
|
||||
holds only metadata plus the stored filename. One retention sweep runs
|
||||
opportunistically so the feed stays bounded.
|
||||
"""
|
||||
from werkzeug.utils import secure_filename
|
||||
import uuid
|
||||
|
||||
error = _collector_auth_or_error()
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
hostname = (request.form.get('hostname') or '').strip()
|
||||
if not hostname:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'hostname is required')
|
||||
|
||||
asset_id = _resolve_asset_for_hostname(hostname)
|
||||
if asset_id is None:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'could not resolve hostname to a computer: {!r}'.format(hostname))
|
||||
|
||||
reason = (request.form.get('reason') or 'Other (free text)').strip()[:128]
|
||||
details = (request.form.get('details') or '').strip() or None
|
||||
|
||||
imagefilename = None
|
||||
imagemimetype = None
|
||||
imagebytesize = None
|
||||
|
||||
upload = request.files.get('screenshot')
|
||||
if upload is not None and upload.filename:
|
||||
data = upload.read()
|
||||
if len(data) > OPERATOR_REPORT_MAX_IMAGE_BYTES:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'screenshot exceeds the {} byte limit'.format(
|
||||
OPERATOR_REPORT_MAX_IMAGE_BYTES))
|
||||
if data:
|
||||
ext = os.path.splitext(secure_filename(upload.filename))[1].lower()
|
||||
if ext != '.png':
|
||||
ext = '.png'
|
||||
imagefilename = 'report-{}{}'.format(uuid.uuid4().hex, ext)
|
||||
imagemimetype = 'image/png'
|
||||
imagebytesize = len(data)
|
||||
report_dir = _operator_report_dir()
|
||||
os.makedirs(report_dir, exist_ok=True)
|
||||
with open(os.path.join(report_dir, imagefilename), 'wb') as handle:
|
||||
handle.write(data)
|
||||
|
||||
report = OperatorReport(
|
||||
assetid=asset_id,
|
||||
reason=reason,
|
||||
details=details,
|
||||
imagefilename=imagefilename,
|
||||
imagemimetype=imagemimetype,
|
||||
imagebytesize=imagebytesize,
|
||||
reportedhostname=hostname,
|
||||
)
|
||||
db.session.add(report)
|
||||
db.session.commit()
|
||||
|
||||
# Keep the feed bounded without a scheduler. Best-effort: a purge failure
|
||||
# must not fail the upload the operator already completed.
|
||||
try:
|
||||
_purge_expired_operator_reports()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
return success_response(report.to_dict(),
|
||||
message='Operator report received', http_code=201)
|
||||
|
||||
|
||||
@endpoints_bp.route('/panel/<int:asset_id>/operatorreports', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_operator_reports(asset_id: int):
|
||||
"""Operator Reports panel: this PC's recent operator issue reports.
|
||||
|
||||
Newest first. Each row carries a hasimage flag so the viewer knows whether
|
||||
to request the screenshot from the serve route below. Runs a retention
|
||||
sweep opportunistically so a viewed PC does not accumulate stale rows.
|
||||
"""
|
||||
try:
|
||||
_purge_expired_operator_reports()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
reports = (OperatorReport.query
|
||||
.filter_by(assetid=asset_id)
|
||||
.order_by(OperatorReport.createdat.desc())
|
||||
.all())
|
||||
return success_response({'reports': [r.to_dict() for r in reports]})
|
||||
|
||||
|
||||
@endpoints_bp.route('/operatorreports/<int:operatorreportid>/image',
|
||||
methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def serve_operator_report_image(operatorreportid: int):
|
||||
"""Serve one operator report's screenshot (user JWT + endpoints.view)."""
|
||||
from shopdb.api import send_upload
|
||||
|
||||
report = db.session.get(OperatorReport, operatorreportid)
|
||||
if report is None or not report.imagefilename:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'No screenshot for this report', http_code=404)
|
||||
return send_upload(_operator_report_dir(), report.imagefilename)
|
||||
|
||||
|
||||
|
||||
@@ -41,6 +41,14 @@ from .routes import endpoints_bp
|
||||
STAGED_AGENT_RELPATH = os.path.join('siteagent', 'shopdbagent.exe')
|
||||
STAGED_TRAY_RELPATH = os.path.join('siteagent', 'shopdb-endpoint-tray.exe')
|
||||
|
||||
# The standalone uninstaller lives in the repo tree (not staged per-instance),
|
||||
# so it ships in every bundle. Resolve it relative to this file:
|
||||
# plugins/endpoints/api/siteagent.py -> repo root is three levels up.
|
||||
UNINSTALLER_PATH = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__))))),
|
||||
'scripts', 'uninstall-shopdbagent.ps1')
|
||||
|
||||
# Public TLS certificate the agent must trust when this deployment terminates
|
||||
# TLS itself with a self-signed or otherwise untrusted certificate. We can only
|
||||
# auto-bundle when THIS app owns the cert file (the dev topology, where gunicorn
|
||||
@@ -144,6 +152,12 @@ FILES
|
||||
appsettings.json Non-secret runtime config (cadence, monitors,
|
||||
launch paths). No credentials.
|
||||
install.ps1 Provisions the credentials and installs the service.
|
||||
uninstall-shopdbagent.ps1
|
||||
Standalone uninstaller. Run elevated to reverse the
|
||||
install: stops and deletes the service, kills agent
|
||||
and tray processes, removes the tray autostart Run
|
||||
key, the Start Menu shortcut, and the credential
|
||||
hive, and deletes the install directory.
|
||||
server.crt Server TLS certificate (present only for self-signed
|
||||
or internal-CA deployments, either auto-detected or
|
||||
uploaded by the operator). install.ps1 imports it
|
||||
@@ -153,10 +167,16 @@ FILES
|
||||
USAGE
|
||||
1. Copy this whole folder to the target endpoint.
|
||||
2. Right-click install.ps1 and Run with PowerShell as administrator. It
|
||||
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
|
||||
copies the agent into Program Files, and starts the service.
|
||||
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
|
||||
copies the agent into Program Files, and starts the service. It also
|
||||
registers the tray to autostart for every user at logon and adds a
|
||||
"ShopDB Agent" Start Menu shortcut for manual relaunch.
|
||||
|
||||
SECURITY
|
||||
UNINSTALL
|
||||
Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the
|
||||
agent, tray autostart, Start Menu shortcut, and credentials.
|
||||
|
||||
SECURITY
|
||||
install.ps1 carries a live collector key in its default -CollectorKey value.
|
||||
Treat this package as a secret. The key is not recoverable from the ShopDB
|
||||
server after it was generated; regenerate the key in the UI to revoke it.
|
||||
@@ -248,7 +268,7 @@ Write-Host "ServiceName : $ServiceName"
|
||||
# 0. Stop and remove any previous install so the file copy is a clean overwrite.
|
||||
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
if ($existing) {{
|
||||
Write-Host "[0/4] Removing existing service $ServiceName ..."
|
||||
Write-Host "[0/6] Removing existing service $ServiceName ..."
|
||||
if ($existing.Status -ne "Stopped") {{
|
||||
try {{ Stop-Service -Name $ServiceName -Force -ErrorAction Stop }}
|
||||
catch {{ Write-Warning "could not stop service: $($_.Exception.Message)" }}
|
||||
@@ -264,7 +284,7 @@ foreach ($name in @("shopdbagent", $trayBase)) {{
|
||||
}}
|
||||
|
||||
# 1. Provision registry credentials (both 64-bit and 32-bit views).
|
||||
Write-Host "[1/4] Provisioning registry HKLM:\\SOFTWARE\\GE\\ShopDB (Registry64 + Registry32) ..."
|
||||
Write-Host "[1/6] Provisioning registry HKLM:\\SOFTWARE\\GE\\ShopDB (Registry64 + Registry32) ..."
|
||||
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "BaseUrl" -Value $BaseUrl
|
||||
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "CollectorKey" -Value $CollectorKey
|
||||
|
||||
@@ -288,7 +308,7 @@ if (-not [string]::IsNullOrWhiteSpace($CertFileName)) {{
|
||||
}}
|
||||
|
||||
# 2. Copy agent files to the install directory.
|
||||
Write-Host "[2/4] Copying agent files to $InstallDir ..."
|
||||
Write-Host "[2/6] Copying agent files to $InstallDir ..."
|
||||
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
||||
Get-ChildItem -Path $sourceDir -File | ForEach-Object {{
|
||||
try {{ Unblock-File -Path $_.FullName -ErrorAction Stop }} catch {{ }}
|
||||
@@ -306,17 +326,118 @@ if (-not $NoTray) {{
|
||||
$traySource = Join-Path $sourceDir $TrayExeName
|
||||
if (Test-Path $traySource) {{
|
||||
Copy-Item -Path $traySource -Destination $InstallDir -Force
|
||||
$installedTray = Join-Path $InstallDir $TrayExeName
|
||||
|
||||
# Autostart the tray for every user at logon via the HKLM Run key (both
|
||||
# 64-bit and 32-bit views, matching the credential hive). This is the
|
||||
# reliable launch guarantee: a plain Start Menu shortcut does not
|
||||
# autostart, and the SYSTEM service cannot cleanly spawn into the
|
||||
# interactive session. The tray itself is single-instance, so a stray
|
||||
# second launch is harmless.
|
||||
Set-HklmValueBothViews -SubKey "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run" -Name "ShopDbEndpointTray" -Value ('"' + $installedTray + '"')
|
||||
|
||||
# Export the branded icon from the tray exe so the shortcut shows the
|
||||
# ShopDB brand instead of the generic exe icon. The tray draws its icon
|
||||
# at runtime (no embedded Win32 icon), so we ask it to write an .ico into
|
||||
# the install dir and point the shortcut at it.
|
||||
$iconPath = Join-Path $InstallDir "shopdb-agent.ico"
|
||||
try {{ & $installedTray --export-icon $iconPath | Out-Null }}
|
||||
catch {{ Write-Warning "could not export shortcut icon: $($_.Exception.Message)" }}
|
||||
|
||||
# Start Menu shortcut so operators can relaunch the tray on demand. This
|
||||
# is discoverability only, not autostart. Written to the machine-wide
|
||||
# (All Users) Programs folder.
|
||||
try {{
|
||||
$commonPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonPrograms)
|
||||
$shortcutPath = Join-Path $commonPrograms "ShopDB Agent.lnk"
|
||||
$shell = New-Object -ComObject WScript.Shell
|
||||
$shortcut = $shell.CreateShortcut($shortcutPath)
|
||||
$shortcut.TargetPath = $installedTray
|
||||
$shortcut.WorkingDirectory = $InstallDir
|
||||
$shortcut.Description = "ShopDB Endpoint Agent tray"
|
||||
if (Test-Path $iconPath) {{ $shortcut.IconLocation = "$iconPath,0" }}
|
||||
$shortcut.Save()
|
||||
Write-Host " created Start Menu shortcut: $shortcutPath"
|
||||
}}
|
||||
catch {{ Write-Warning "could not create Start Menu shortcut: $($_.Exception.Message)" }}
|
||||
|
||||
# Launch the tray once now so it appears without waiting for the next
|
||||
# logon. Single-instance guard makes a duplicate launch a no-op.
|
||||
try {{ Start-Process -FilePath $installedTray -WorkingDirectory $InstallDir }}
|
||||
catch {{ Write-Warning "could not start tray: $($_.Exception.Message)" }}
|
||||
}}
|
||||
}}
|
||||
|
||||
# 3. Install and start the service. The agent supports its own service install.
|
||||
Write-Host "[3/4] Installing and starting the service ..."
|
||||
Write-Host "[3/6] Installing and starting the service ..."
|
||||
$installedAgent = Join-Path $InstallDir "shopdbagent.exe"
|
||||
$binPath = '"' + $installedAgent + '"'
|
||||
sc.exe create $ServiceName binPath= $binPath start= auto DisplayName= "ShopDB Endpoint Agent" | Out-Null
|
||||
sc.exe start $ServiceName | Out-Null
|
||||
|
||||
Write-Host "[4/4] Done." -ForegroundColor Green
|
||||
# 4. Confirm the service (and tray, when installed) actually reached a running
|
||||
# state before we destroy the installer. sc.exe start returns before the service
|
||||
# is up, and a failed start would otherwise leave a half-installed endpoint with
|
||||
# the secret-bearing install.ps1 already deleted. Poll with a short backoff and
|
||||
# retry the start a few times; only proceed to cleanup once healthy.
|
||||
Write-Host "[4/6] Verifying agent runtime ..."
|
||||
$serviceOk = $false
|
||||
for ($attempt = 1; $attempt -le 5; $attempt++) {{
|
||||
Start-Sleep -Seconds 3
|
||||
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
if ($svc -and $svc.Status -eq "Running") {{ $serviceOk = $true; break }}
|
||||
Write-Host " service not running yet (attempt $attempt/5); retrying start ..."
|
||||
try {{ Start-Service -Name $ServiceName -ErrorAction Stop }} catch {{ }}
|
||||
}}
|
||||
if (-not $serviceOk) {{
|
||||
throw "Service $ServiceName did not reach Running after 5 attempts. Leaving install.ps1 in place for diagnosis; re-run once resolved."
|
||||
}}
|
||||
Write-Host " service is running." -ForegroundColor Green
|
||||
|
||||
$trayBase2 = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
|
||||
if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
|
||||
$trayOk = $false
|
||||
for ($attempt = 1; $attempt -le 5; $attempt++) {{
|
||||
if (Get-Process -Name $trayBase2 -ErrorAction SilentlyContinue) {{ $trayOk = $true; break }}
|
||||
Write-Host " tray not running yet (attempt $attempt/5); relaunching ..."
|
||||
try {{ Start-Process -FilePath (Join-Path $InstallDir $TrayExeName) -WorkingDirectory $InstallDir }} catch {{ }}
|
||||
Start-Sleep -Seconds 2
|
||||
}}
|
||||
if ($trayOk) {{ Write-Host " tray is running." -ForegroundColor Green }}
|
||||
else {{ Write-Warning "tray did not confirm running; it will retry at next logon via the Run key." }}
|
||||
}}
|
||||
|
||||
# 5. Preserve the install artifacts (minus the secret-bearing install.ps1) under
|
||||
# ProgramData so the uninstaller and appsettings/cert are available later, then
|
||||
# scrub install.ps1 from wherever it was run. install.ps1 carries the live
|
||||
# collector key in its default parameter, so it must not linger on the endpoint.
|
||||
Write-Host "[5/6] Archiving install artifacts and removing install.ps1 ..."
|
||||
$dataDir = Join-Path $env:ProgramData "ShopDB\\EndpointAgent"
|
||||
New-Item -ItemType Directory -Force -Path $dataDir | Out-Null
|
||||
foreach ($name in @("uninstall-shopdbagent.ps1", "appsettings.json", "README.txt", $CertFileName)) {{
|
||||
if ([string]::IsNullOrWhiteSpace($name)) {{ continue }}
|
||||
$src = Join-Path $sourceDir $name
|
||||
if (Test-Path $src) {{
|
||||
try {{ Copy-Item -Path $src -Destination $dataDir -Force }}
|
||||
catch {{ Write-Warning "could not archive $name : $($_.Exception.Message)" }}
|
||||
}}
|
||||
}}
|
||||
$uninstaller = Join-Path $dataDir "uninstall-shopdbagent.ps1"
|
||||
|
||||
$installSelf = Join-Path $sourceDir "install.ps1"
|
||||
if (Test-Path $installSelf) {{
|
||||
try {{ Remove-Item -Path $installSelf -Force -ErrorAction Stop; Write-Host " removed install.ps1 (carried the collector key)." }}
|
||||
catch {{ Write-Warning "could not remove install.ps1 - delete it manually, it contains the collector key: $($_.Exception.Message)" }}
|
||||
}}
|
||||
|
||||
Write-Host "[6/6] Done." -ForegroundColor Green
|
||||
if (Test-Path $uninstaller) {{
|
||||
Write-Host "Uninstaller: $uninstaller" -ForegroundColor Cyan
|
||||
Write-Host " To remove the agent, run that script from an elevated PowerShell."
|
||||
}}
|
||||
else {{
|
||||
Write-Warning "uninstaller was not bundled; fetch uninstall-shopdbagent.ps1 from the ShopDB repo scripts/ folder if needed."
|
||||
}}
|
||||
""".format(
|
||||
baseurl=baseurl_literal,
|
||||
collectorkey=collectorkey_literal,
|
||||
@@ -421,6 +542,9 @@ def package_siteagent():
|
||||
cadenceseconds, certfilename))
|
||||
archive.writestr('README.txt', _README)
|
||||
|
||||
if os.path.isfile(UNINSTALLER_PATH):
|
||||
archive.write(UNINSTALLER_PATH, 'uninstall-shopdbagent.ps1')
|
||||
|
||||
agent = _staged_path(STAGED_AGENT_RELPATH)
|
||||
if os.path.isfile(agent):
|
||||
archive.write(agent, 'shopdbagent.exe')
|
||||
|
||||
@@ -185,6 +185,36 @@ def requires_target(action):
|
||||
return action_targetkind(action) is not None
|
||||
|
||||
|
||||
# Monitor attachment as a policy action. A monitor definition is bound to a PC
|
||||
# type by carrying a ConfigurationPolicyAction whose name is 'monitor:<id>'.
|
||||
# Distinct names let one policy attach several monitors and fold naturally
|
||||
# (first-match-wins per name), and they are validated in the policy layer but
|
||||
# never enqueued as commands because ALLOWED_ACTIONS_FOR intersects with
|
||||
# COMMAND_ALLOWLIST, which carries no monitor: entries.
|
||||
MONITOR_ACTION_PREFIX = 'monitor:'
|
||||
|
||||
|
||||
def monitor_action_name(endpointmonitorid):
|
||||
"""Build the policy action name that attaches a monitor definition."""
|
||||
return f'{MONITOR_ACTION_PREFIX}{endpointmonitorid}'
|
||||
|
||||
|
||||
def is_monitor_action(action):
|
||||
"""True when action names a monitor attachment (monitor:<id>)."""
|
||||
return isinstance(action, str) and action.startswith(MONITOR_ACTION_PREFIX)
|
||||
|
||||
|
||||
def monitor_id_from_action(action):
|
||||
"""The monitor id an action attaches, or None when it is not a monitor action."""
|
||||
if not is_monitor_action(action):
|
||||
return None
|
||||
raw = action[len(MONITOR_ACTION_PREFIX):]
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
|
||||
"""The action names an endpoint may run.
|
||||
|
||||
@@ -211,6 +241,44 @@ def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
|
||||
# behavior). This is intentionally permissive; deny-by-default would be
|
||||
# a separate, explicit setting.
|
||||
return base
|
||||
# Attaching a monitor expresses intent to watch a process/service AND to
|
||||
# control it: the panel renders that row's Start/Stop/Status buttons, so the
|
||||
# gate must permit the matching generic primitives or every click 409s
|
||||
# "not permitted by policy". Expand each monitor:<id> to its kind's control
|
||||
# actions before intersecting.
|
||||
allowed = allowed | _monitor_control_actions(allowed)
|
||||
# Never widen past the code-level allowlist: policy can only gate within it.
|
||||
return base & allowed
|
||||
|
||||
|
||||
def _monitor_control_actions(action_names):
|
||||
"""Control primitives granted by the monitor: attachments in a policy.
|
||||
|
||||
A process monitor grants process_start/stop/status; a service monitor grants
|
||||
service_start/stop/status. The monitor's kind is read from the catalog, so a
|
||||
definition's kind is the single source of truth for what its row can do.
|
||||
"""
|
||||
ids = set()
|
||||
for name in action_names:
|
||||
if isinstance(name, str) and name.startswith(MONITOR_ACTION_PREFIX):
|
||||
monitor_id = monitor_id_from_action(name)
|
||||
if monitor_id is not None:
|
||||
ids.add(monitor_id)
|
||||
if not ids:
|
||||
return set()
|
||||
|
||||
try:
|
||||
from .models import EndpointMonitor
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
granted = set()
|
||||
for monitor in EndpointMonitor.query.filter(
|
||||
EndpointMonitor.endpointmonitorid.in_(ids)).all():
|
||||
kind = (monitor.kind or '').lower()
|
||||
if kind == 'process':
|
||||
granted |= {'process_start', 'process_stop', 'process_status'}
|
||||
elif kind == 'service':
|
||||
granted |= {'service_start', 'service_stop', 'service_status'}
|
||||
return granted
|
||||
|
||||
|
||||
@@ -18,6 +18,12 @@ export default [
|
||||
component: () => import('./views/AgentKeySettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
|
||||
},
|
||||
{
|
||||
path: 'settings/endpoints/monitors',
|
||||
name: 'endpoints-monitors-settings',
|
||||
component: () => import('./views/MonitorsSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
|
||||
},
|
||||
// TEMPORARY / DEV ONLY. A manual bench to exercise the update_app and
|
||||
// run_script delivery path end to end while Cameron builds the real UI.
|
||||
// Gated on endpoints.deploy inside the view; remove once the production
|
||||
|
||||
@@ -307,7 +307,7 @@
|
||||
</section>
|
||||
|
||||
<!-- ====================================================================
|
||||
Agent configuration (placeholder)
|
||||
Agent configuration
|
||||
================================================================= -->
|
||||
<section class="settings-section">
|
||||
<h3>Agent configuration</h3>
|
||||
@@ -319,11 +319,16 @@
|
||||
stamped into the build above so a deployed agent needs no local edits.
|
||||
</p>
|
||||
<p class="field-hint">
|
||||
<strong>Coming soon.</strong> These controls arrive with the C# agent
|
||||
runtime. Until then the agent uses its built-in defaults.
|
||||
Monitored processes and services are defined as reusable monitors and
|
||||
attached to PC types through site policies, so a deployed agent fetches
|
||||
the right set for its type.
|
||||
</p>
|
||||
<button class="btn btn-secondary" type="button" disabled>
|
||||
Edit configuration (coming soon)
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
@click="editConfiguration"
|
||||
>
|
||||
Edit configuration
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -332,8 +337,10 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import api from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const busy = ref(false)
|
||||
const key = ref(null)
|
||||
@@ -540,6 +547,10 @@ async function buildSiteAgent() {
|
||||
agentbusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function editConfiguration() {
|
||||
router.push({ name: 'endpoints-monitors-settings' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
232
plugins/endpoints/frontend/views/MonitorsSettings.vue
Normal file
232
plugins/endpoints/frontend/views/MonitorsSettings.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Monitor Definitions</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Monitor</button>
|
||||
</div>
|
||||
|
||||
<p class="setting-description">
|
||||
A monitor definition is a reusable process or service the agent can watch,
|
||||
with a bespoke display label separate from the raw OS name. Shipped monitors
|
||||
are product-owned and read-only. Attach monitors to PC types through site
|
||||
policies so a deployed agent fetches the right set for its type.
|
||||
</p>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Display name</th>
|
||||
<th>Kind</th>
|
||||
<th>OS identity</th>
|
||||
<th>Launch path</th>
|
||||
<th>Active</th>
|
||||
<th>Source</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in monitors" :key="m.endpointmonitorid">
|
||||
<td>{{ m.displayname }}</td>
|
||||
<td>{{ m.kind }}</td>
|
||||
<td class="cell-truncate" :title="osidentity(m)">{{ osidentity(m) || '-' }}</td>
|
||||
<td class="cell-truncate" :title="m.launchpath">{{ m.launchpath || '-' }}</td>
|
||||
<td>{{ m.isactive ? 'Yes' : 'No' }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="m.source === 'local' ? 'badge-secondary' : 'badge-warning'">
|
||||
{{ m.source }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" :disabled="m.source !== 'local'" @click="openModal(m)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" :disabled="m.source !== 'local'" @click="remove(m)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="monitors.length === 0">
|
||||
<td colspan="7" style="text-align:center; color: var(--text-light);">No monitor definitions</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 Monitor' : 'Add Monitor' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Display name *</label>
|
||||
<input v-model="form.displayname" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Kind *</label>
|
||||
<select v-model="form.kind" class="form-control">
|
||||
<option value="process">process</option>
|
||||
<option value="service">service</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="form.kind === 'process'" class="form-group">
|
||||
<label>Process name * <span class="hint">(executable the agent matches, e.g. agent.exe)</span></label>
|
||||
<input v-model="form.processname" type="text" class="form-control" />
|
||||
</div>
|
||||
<div v-else class="form-group">
|
||||
<label>Service name * <span class="hint">(Windows service name)</span></label>
|
||||
<input v-model="form.servicename" type="text" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Launch path <span class="hint">(optional; starts the app)</span></label>
|
||||
<input v-model="form.launchpath" type="text" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Data collector path <span class="hint">(optional)</span></label>
|
||||
<input v-model="form.datacollectorpath" type="text" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Log collector path <span class="hint">(optional)</span></label>
|
||||
<input v-model="form.logcollectorpath" type="text" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input v-model="form.isactive" type="checkbox" />
|
||||
Active
|
||||
</label>
|
||||
</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 { endpointsApi } from '@/api'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const monitors = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const form = ref(emptyForm())
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
kind: 'process',
|
||||
displayname: '',
|
||||
processname: '',
|
||||
servicename: '',
|
||||
launchpath: '',
|
||||
datacollectorpath: '',
|
||||
logcollectorpath: '',
|
||||
isactive: true
|
||||
}
|
||||
}
|
||||
|
||||
function osidentity(m) {
|
||||
return m.kind === 'service' ? m.servicename : m.processname
|
||||
}
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await endpointsApi.monitors.list()
|
||||
monitors.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading monitor definitions:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
error.value = ''
|
||||
if (item) {
|
||||
form.value = {
|
||||
kind: item.kind || 'process',
|
||||
displayname: item.displayname || '',
|
||||
processname: item.processname || '',
|
||||
servicename: item.servicename || '',
|
||||
launchpath: item.launchpath || '',
|
||||
datacollectorpath: item.datacollectorpath || '',
|
||||
logcollectorpath: item.logcollectorpath || '',
|
||||
isactive: item.isactive !== false
|
||||
}
|
||||
} else {
|
||||
form.value = emptyForm()
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const payload = {
|
||||
kind: form.value.kind,
|
||||
displayname: form.value.displayname,
|
||||
processname: form.value.processname,
|
||||
servicename: form.value.servicename,
|
||||
launchpath: form.value.launchpath,
|
||||
datacollectorpath: form.value.datacollectorpath,
|
||||
logcollectorpath: form.value.logcollectorpath,
|
||||
isactive: form.value.isactive
|
||||
}
|
||||
if (editing.value) {
|
||||
await endpointsApi.monitors.update(editing.value.endpointmonitorid, payload)
|
||||
} else {
|
||||
await endpointsApi.monitors.create(payload)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save monitor')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(m) {
|
||||
if (!confirm(`Delete monitor "${m.displayname}"?`)) return
|
||||
try {
|
||||
await endpointsApi.monitors.remove(m.endpointmonitorid)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cell-truncate {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hint { color: var(--text-light); font-weight: normal; }
|
||||
</style>
|
||||
151
plugins/endpoints/migrations/versions/0006_endpoint_monitors.py
Normal file
151
plugins/endpoints/migrations/versions/0006_endpoint_monitors.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Reusable endpoint monitor definitions (the monitor catalog).
|
||||
|
||||
Adds endpointmonitors: one row per process or service the agent can watch, with
|
||||
a bespoke display label separate from the raw OS name and optional launch and
|
||||
collector paths. A definition is the "what"; attaching it to a PC type is a
|
||||
separate site-policy concern.
|
||||
|
||||
Seeds the four monitors we ship today, lifted from the agent's appsettings.json
|
||||
so the catalog matches what a deployed agent already watches:
|
||||
|
||||
process dncMain.exe -> eDNC
|
||||
process udc.exe -> Universal Data Collector
|
||||
process agent.exe -> MTConnect
|
||||
service MTConnect UDC Agent -> MTConnect
|
||||
|
||||
Shipped rows are product-owned (source='shipped'): the CRUD API refuses to edit
|
||||
or delete them. Launch paths mirror the agent's launchpaths block; collector and
|
||||
log paths are left null until site paths are supplied.
|
||||
|
||||
Idempotent: creation checks the live schema first so a db.create_all() site
|
||||
(which already built the table) re-runs clean on strict MySQL 8. Seeding is a
|
||||
separate check on row count, so a site that built the table without the shipped
|
||||
rows still gets them.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0006monitors'
|
||||
down_revision = 'endpoints0005cmdpin'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
return inspect(op.get_bind()).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _has_table('endpointmonitors'):
|
||||
op.create_table(
|
||||
'endpointmonitors',
|
||||
sa.Column('endpointmonitorid', sa.Integer(), nullable=False),
|
||||
sa.Column('kind', sa.String(length=16), nullable=False),
|
||||
sa.Column('displayname', sa.String(length=128), nullable=False),
|
||||
sa.Column('processname', sa.String(length=255), nullable=True),
|
||||
sa.Column('servicename', sa.String(length=255), nullable=True),
|
||||
sa.Column('launchpath', sa.String(length=512), nullable=True),
|
||||
sa.Column('datacollectorpath', sa.String(length=512), nullable=True),
|
||||
sa.Column('logcollectorpath', sa.String(length=512), nullable=True),
|
||||
sa.Column('source', sa.String(length=16), nullable=False,
|
||||
server_default='local'),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False,
|
||||
server_default=sa.true()),
|
||||
sa.Column('createdat', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('endpointmonitorid'),
|
||||
sa.UniqueConstraint('kind', 'displayname',
|
||||
name='uq_endpointmonitors_kinddisplay'),
|
||||
)
|
||||
op.create_index('idx_endpointmonitors_kind', 'endpointmonitors', ['kind'])
|
||||
op.create_index('idx_endpointmonitors_source', 'endpointmonitors', ['source'])
|
||||
|
||||
# Seed the shipped catalog independently of table creation so a
|
||||
# db.create_all() site (which already built the table) still gets the rows.
|
||||
existing = bind.execute(
|
||||
sa.text('SELECT COUNT(*) FROM endpointmonitors')).scalar()
|
||||
if existing:
|
||||
return
|
||||
|
||||
monitors = sa.table(
|
||||
'endpointmonitors',
|
||||
sa.column('kind', sa.String),
|
||||
sa.column('displayname', sa.String),
|
||||
sa.column('processname', sa.String),
|
||||
sa.column('servicename', sa.String),
|
||||
sa.column('launchpath', sa.String),
|
||||
sa.column('datacollectorpath', sa.String),
|
||||
sa.column('logcollectorpath', sa.String),
|
||||
sa.column('source', sa.String),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
sa.column('createdat', sa.DateTime),
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
op.bulk_insert(monitors, [
|
||||
{
|
||||
'kind': 'process',
|
||||
'displayname': 'eDNC',
|
||||
'processname': 'dncMain.exe',
|
||||
'servicename': None,
|
||||
'launchpath': r'C:\Program Files (x86)\Dnc\bin\DncMain.exe',
|
||||
'datacollectorpath': None,
|
||||
'logcollectorpath': None,
|
||||
'source': 'shipped',
|
||||
'isactive': True,
|
||||
'createdat': now,
|
||||
},
|
||||
{
|
||||
'kind': 'process',
|
||||
'displayname': 'Universal Data Collector',
|
||||
'processname': 'udc.exe',
|
||||
'servicename': None,
|
||||
'launchpath': r'C:\Program Files\UDC\UDC.exe',
|
||||
'datacollectorpath': None,
|
||||
'logcollectorpath': None,
|
||||
'source': 'shipped',
|
||||
'isactive': True,
|
||||
'createdat': now,
|
||||
},
|
||||
{
|
||||
'kind': 'process',
|
||||
'displayname': 'MTConnect',
|
||||
'processname': 'agent.exe',
|
||||
'servicename': None,
|
||||
'launchpath': r'C:\ProgramData\udc\mtconnect_udc\agent\run.bat',
|
||||
'datacollectorpath': None,
|
||||
'logcollectorpath': None,
|
||||
'source': 'shipped',
|
||||
'isactive': True,
|
||||
'createdat': now,
|
||||
},
|
||||
{
|
||||
'kind': 'service',
|
||||
'displayname': 'MTConnect',
|
||||
'processname': None,
|
||||
'servicename': 'MTConnect UDC Agent',
|
||||
'launchpath': None,
|
||||
'datacollectorpath': None,
|
||||
'logcollectorpath': None,
|
||||
'source': 'shipped',
|
||||
'isactive': True,
|
||||
'createdat': now,
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
def downgrade():
|
||||
if not _has_table('endpointmonitors'):
|
||||
return
|
||||
|
||||
op.drop_index('idx_endpointmonitors_source', table_name='endpointmonitors')
|
||||
op.drop_index('idx_endpointmonitors_kind', table_name='endpointmonitors')
|
||||
op.drop_table('endpointmonitors')
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Ship the shopfloor app monitors as a read-only Site Policy.
|
||||
|
||||
Wraps the four shipped endpointmonitors (eDNC, Universal Data Collector,
|
||||
MTConnect process, MTConnect UDC Agent service) in a product-owned policy bundle
|
||||
so the agent stops carrying them as hardcoded appsettings.json defaults and reads
|
||||
its watch-list from the server instead.
|
||||
|
||||
Seeds two computers-plugin rows, both source='shipped' (read-only in the UI, the
|
||||
CRUD API refuses to edit or delete them):
|
||||
|
||||
ConfigurationPolicy "Shopfloor App Monitors"
|
||||
scope {"type":"true"} (every endpoint), one action per shipped monitor,
|
||||
named 'monitor:<endpointmonitorid>', mode='gate', desiredstate=NULL.
|
||||
|
||||
Gate, not desired, on purpose. A monitor REPORTS a process/service state and
|
||||
exposes Start/Stop in the Monitor UI; the state read happens on manual
|
||||
refresh and on agent check-in, so attaching a monitor never generates fleet
|
||||
traffic on its own and a policy edit does not push a collection. A 'desired'
|
||||
running state is a separate, deliberate opt-in that keeps an app alive by
|
||||
trying to START the process when a launch path is mapped - never something
|
||||
the product ships switched on for every endpoint.
|
||||
|
||||
BusinessPolicy "RPCA Shopfloor App Monitors"
|
||||
the site-level wrapper, carrying the Configuration Policy as its one item.
|
||||
|
||||
Monitors are looked up by identity (kind, displayname), never by a hardcoded id,
|
||||
so the bundle survives id drift across installs.
|
||||
|
||||
Lives in the endpoints chain, after 0006, because the monitor rows it references
|
||||
are seeded there. The computers policy tables are created by the core baseline
|
||||
before per-plugin chains run; this migration still guards on their presence and
|
||||
no-ops cleanly if they are absent, and is idempotent on the policy/business-policy
|
||||
names so re-running never duplicates the bundle.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0007shippedpolicy'
|
||||
down_revision = 'endpoints0006monitors'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
CONFIG_POLICY_NAME = 'Shopfloor App Monitors'
|
||||
BUSINESS_POLICY_NAME = 'RPCA Shopfloor App Monitors'
|
||||
|
||||
# The shipped monitors this bundle attaches, by identity (kind, displayname).
|
||||
_SHIPPED_MONITORS = (
|
||||
('process', 'eDNC'),
|
||||
('process', 'Universal Data Collector'),
|
||||
('process', 'MTConnect'),
|
||||
('service', 'MTConnect'),
|
||||
)
|
||||
|
||||
|
||||
def _tables(bind):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
return set(inspect(bind).get_table_names())
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
tables = _tables(bind)
|
||||
|
||||
# The bundle lives in the computers policy tables and references the
|
||||
# endpoints monitor catalog. If either side is missing (a partial site),
|
||||
# do nothing rather than fail - a later run reconciles once both exist.
|
||||
required = {'endpointmonitors', 'configurationpolicies',
|
||||
'configurationpolicyactions', 'businesspolicies',
|
||||
'businesspolicyitems'}
|
||||
if not required.issubset(tables):
|
||||
return
|
||||
|
||||
# Idempotent: never seed the bundle twice.
|
||||
existing_config = bind.execute(
|
||||
sa.text('SELECT configurationpolicyid FROM configurationpolicies '
|
||||
'WHERE name = :name'),
|
||||
{'name': CONFIG_POLICY_NAME}).first()
|
||||
if existing_config:
|
||||
return
|
||||
|
||||
# Resolve the shipped monitor ids by identity. Skip any that a site has
|
||||
# not got; attach whatever shipped monitors are present.
|
||||
monitor_ids = []
|
||||
for kind, displayname in _SHIPPED_MONITORS:
|
||||
row = bind.execute(
|
||||
sa.text('SELECT endpointmonitorid FROM endpointmonitors '
|
||||
'WHERE kind = :kind AND displayname = :displayname'),
|
||||
{'kind': kind, 'displayname': displayname}).first()
|
||||
if row:
|
||||
monitor_ids.append(row[0])
|
||||
|
||||
if not monitor_ids:
|
||||
return
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
config_policies = sa.table(
|
||||
'configurationpolicies',
|
||||
sa.column('name', sa.String),
|
||||
sa.column('description', sa.Text),
|
||||
sa.column('scopejson', sa.Text),
|
||||
sa.column('source', sa.String),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(config_policies, [{
|
||||
'name': CONFIG_POLICY_NAME,
|
||||
'description': 'Product-shipped watch-list for the shopfloor apps '
|
||||
'(eDNC, Universal Data Collector, MTConnect). Read-only.',
|
||||
'scopejson': '{"type": "true"}',
|
||||
'source': 'shipped',
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
|
||||
config_policy_id = bind.execute(
|
||||
sa.text('SELECT configurationpolicyid FROM configurationpolicies '
|
||||
'WHERE name = :name'),
|
||||
{'name': CONFIG_POLICY_NAME}).scalar()
|
||||
|
||||
actions = sa.table(
|
||||
'configurationpolicyactions',
|
||||
sa.column('configurationpolicyid', sa.Integer),
|
||||
sa.column('action', sa.String),
|
||||
sa.column('mode', sa.String),
|
||||
sa.column('desiredstate', sa.Text),
|
||||
sa.column('params', sa.Text),
|
||||
sa.column('intervalminutes', sa.Integer),
|
||||
sa.column('sortorder', sa.Integer),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(actions, [{
|
||||
'configurationpolicyid': config_policy_id,
|
||||
'action': f'monitor:{monitorid}',
|
||||
'mode': 'gate',
|
||||
'desiredstate': None,
|
||||
'params': None,
|
||||
'intervalminutes': None,
|
||||
'sortorder': index,
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
} for index, monitorid in enumerate(monitor_ids)])
|
||||
|
||||
business_policies = sa.table(
|
||||
'businesspolicies',
|
||||
sa.column('name', sa.String),
|
||||
sa.column('description', sa.Text),
|
||||
sa.column('priority', sa.Integer),
|
||||
sa.column('source', sa.String),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(business_policies, [{
|
||||
'name': BUSINESS_POLICY_NAME,
|
||||
'description': 'Product-shipped Site Policy that applies the shopfloor '
|
||||
'app monitors to every endpoint. Read-only.',
|
||||
'priority': 100,
|
||||
'source': 'shipped',
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
|
||||
business_policy_id = bind.execute(
|
||||
sa.text('SELECT businesspolicyid FROM businesspolicies '
|
||||
'WHERE name = :name'),
|
||||
{'name': BUSINESS_POLICY_NAME}).scalar()
|
||||
|
||||
items = sa.table(
|
||||
'businesspolicyitems',
|
||||
sa.column('businesspolicyid', sa.Integer),
|
||||
sa.column('configurationpolicyid', sa.Integer),
|
||||
sa.column('sortorder', sa.Integer),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(items, [{
|
||||
'businesspolicyid': business_policy_id,
|
||||
'configurationpolicyid': config_policy_id,
|
||||
'sortorder': 0,
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
tables = _tables(bind)
|
||||
if 'businesspolicies' not in tables or 'configurationpolicies' not in tables:
|
||||
return
|
||||
|
||||
# Clear children explicitly rather than lean on ON DELETE CASCADE, which a
|
||||
# test DB (sqlite) does not enforce by default.
|
||||
business_policy_id = bind.execute(
|
||||
sa.text('SELECT businesspolicyid FROM businesspolicies '
|
||||
'WHERE name = :name'),
|
||||
{'name': BUSINESS_POLICY_NAME}).scalar()
|
||||
config_policy_id = bind.execute(
|
||||
sa.text('SELECT configurationpolicyid FROM configurationpolicies '
|
||||
'WHERE name = :name'),
|
||||
{'name': CONFIG_POLICY_NAME}).scalar()
|
||||
|
||||
if business_policy_id is not None:
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM businesspolicyitems '
|
||||
'WHERE businesspolicyid = :id'),
|
||||
{'id': business_policy_id})
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM businesspolicies WHERE businesspolicyid = :id'),
|
||||
{'id': business_policy_id})
|
||||
if config_policy_id is not None:
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM configurationpolicyactions '
|
||||
'WHERE configurationpolicyid = :id'),
|
||||
{'id': config_policy_id})
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM configurationpolicies '
|
||||
'WHERE configurationpolicyid = :id'),
|
||||
{'id': config_policy_id})
|
||||
51
plugins/endpoints/migrations/versions/0008_endpoint_drift.py
Normal file
51
plugins/endpoints/migrations/versions/0008_endpoint_drift.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Cache the warn-only reconciler drift on the endpoint state.
|
||||
|
||||
Adds endpointstates.driftjson: a per-asset JSON snapshot of the monitors a site
|
||||
policy (or the shipped bundle) expects to be running on an endpoint but which the
|
||||
latest agent report does not show as running. It is computed server-side on every
|
||||
agent check-in and overwritten in place, the same ledger pattern the other state
|
||||
snapshots (servicesjson, portsjson) already use - one current row per asset, no
|
||||
history. Warn-only: nothing here is auto-remediated, it exists so an operator can
|
||||
see the drift and intervene manually.
|
||||
|
||||
Nullable so an endpoint that has never checked in since the column landed reads
|
||||
as "never computed" rather than "no drift".
|
||||
|
||||
Idempotent: the column-add checks the live schema first, so a db.create_all()
|
||||
site (which built the column from the model already) and a re-run both stay clean
|
||||
on strict MySQL 8.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0008drift'
|
||||
down_revision = 'endpoints0007shippedpolicy'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_column(table_name, column_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
inspector = inspect(op.get_bind())
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(col['name'] == column_name
|
||||
for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade():
|
||||
if _has_column('endpointstates', 'driftjson'):
|
||||
return
|
||||
op.add_column(
|
||||
'endpointstates',
|
||||
sa.Column('driftjson', sa.Text(length=16777215), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
if not _has_column('endpointstates', 'driftjson'):
|
||||
return
|
||||
op.drop_column('endpointstates', 'driftjson')
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Operator issue reports (tray screenshot + free-text notes).
|
||||
|
||||
Adds operatorreports: one row per operator-submitted issue report for a PC
|
||||
asset. The screenshot bytes live on disk under the instance uploads dir; this
|
||||
table holds only metadata plus the stored file name so the image can be served
|
||||
back and purged on retention. Reports are transient - a retention window
|
||||
(default 7 days) purges old rows and their files - so this is a rolling
|
||||
operator-signal feed, not a permanent record.
|
||||
|
||||
Idempotent: creation checks the live schema first so a db.create_all() site
|
||||
(which already built the table) re-runs clean on strict MySQL 8.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0009opreports'
|
||||
down_revision = 'endpoints0008drift'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
return inspect(op.get_bind()).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade():
|
||||
if _has_table('operatorreports'):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
'operatorreports',
|
||||
sa.Column('operatorreportid', sa.Integer(), nullable=False),
|
||||
sa.Column('assetid', sa.Integer(), nullable=False),
|
||||
sa.Column('reason', sa.String(length=128), nullable=False,
|
||||
server_default='Other (free text)'),
|
||||
sa.Column('details', sa.Text(), nullable=True),
|
||||
sa.Column('imagefilename', sa.String(length=255), nullable=True),
|
||||
sa.Column('imagemimetype', sa.String(length=64), nullable=True),
|
||||
sa.Column('imagebytesize', sa.Integer(), nullable=True),
|
||||
sa.Column('reportedhostname', sa.String(length=255), nullable=True),
|
||||
sa.Column('createdat', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('operatorreportid'),
|
||||
sa.ForeignKeyConstraint(
|
||||
['assetid'], ['assets.assetid'],
|
||||
name='fk_operatorreports_asset', ondelete='CASCADE'),
|
||||
)
|
||||
op.create_index('idx_operatorreports_asset', 'operatorreports', ['assetid'])
|
||||
op.create_index('idx_operatorreports_createdat', 'operatorreports',
|
||||
['createdat'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
if not _has_table('operatorreports'):
|
||||
return
|
||||
|
||||
op.drop_index('idx_operatorreports_createdat', table_name='operatorreports')
|
||||
op.drop_index('idx_operatorreports_asset', table_name='operatorreports')
|
||||
op.drop_table('operatorreports')
|
||||
@@ -3,7 +3,10 @@
|
||||
from .endpoint import (
|
||||
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
|
||||
)
|
||||
from .monitor import EndpointMonitor
|
||||
from .operatorreport import OperatorReport
|
||||
|
||||
__all__ = [
|
||||
'EndpointState', 'EndpointEvent', 'EndpointCommand', 'EndpointApplication',
|
||||
'EndpointMonitor', 'OperatorReport',
|
||||
]
|
||||
|
||||
@@ -75,6 +75,13 @@ class EndpointState(db.Model):
|
||||
interfacesjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
scheduledtasksjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Warn-only reconciler output, refreshed on every agent check-in in the same
|
||||
# commit as servicesjson. Each entry names a policy/shipped-attached monitor
|
||||
# the endpoint is expected to be running but the latest snapshot does not
|
||||
# report as running, so an operator sees the drift and intervenes manually.
|
||||
# This is warn-only: nothing here is auto-remediated. Null = never computed.
|
||||
driftjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Last-known MTConnect agent run state, cached so the WebUI toggle can show
|
||||
# a real state between command polls. Set by the agent's mtconnect_status
|
||||
# result. Null = never reported (unknown).
|
||||
|
||||
87
plugins/endpoints/models/monitor.py
Normal file
87
plugins/endpoints/models/monitor.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Endpoint monitor definitions (the reusable monitor catalog).
|
||||
|
||||
A monitor definition is the "what": a named process or service the agent should
|
||||
watch, with a bespoke display label separate from the raw OS name, plus optional
|
||||
launch and collector paths. Definitions are the noun that a configuration policy
|
||||
action points at; attaching a monitor to a PC type is a policy concern, not a
|
||||
property of the definition itself.
|
||||
|
||||
Two sources:
|
||||
|
||||
shipped Internal apps we ship (eDNC, MTConnect, UDC). Seeded by migration.
|
||||
Read-only in the UI: name and identity are fixed by the product.
|
||||
local User-defined monitors built on the Settings page.
|
||||
|
||||
A definition is watched on an endpoint when a site policy in effect for that
|
||||
endpoint's PC type includes a monitor action pointing at it.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
def _utciso(value):
|
||||
"""Naive-UTC datetime -> ISO string marked UTC, or None."""
|
||||
return value.isoformat() + 'Z' if value else None
|
||||
|
||||
|
||||
class EndpointMonitor(db.Model):
|
||||
"""One reusable process or service monitor definition."""
|
||||
|
||||
__tablename__ = 'endpointmonitors'
|
||||
|
||||
endpointmonitorid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# 'process' or 'service'. Decides which identity column is authoritative and
|
||||
# which start/stop/status actions the row exposes.
|
||||
kind = db.Column(db.String(16), nullable=False)
|
||||
|
||||
# The bespoke label operators see (e.g. 'MTConnect'). Distinct from the raw
|
||||
# OS name because agent.exe is not obviously MTConnect.
|
||||
displayname = db.Column(db.String(128), nullable=False)
|
||||
|
||||
# OS identity. For a process monitor, processname is the executable the agent
|
||||
# matches (e.g. 'agent.exe'); for a service monitor, servicename is the
|
||||
# Windows service name (e.g. 'MTConnect UDC Agent'). Exactly one is the
|
||||
# authoritative identity for a given kind; the other stays null.
|
||||
processname = db.Column(db.String(255), nullable=True)
|
||||
servicename = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# Optional paths supplied per monitor. launchpath starts the app; the
|
||||
# collector paths point the agent at where the app writes data and logs.
|
||||
launchpath = db.Column(db.String(512), nullable=True)
|
||||
datacollectorpath = db.Column(db.String(512), nullable=True)
|
||||
logcollectorpath = db.Column(db.String(512), nullable=True)
|
||||
|
||||
# 'shipped' (product-owned, seeded) or 'local' (user-built). Shipped rows are
|
||||
# not editable or deletable through the CRUD API.
|
||||
source = db.Column(db.String(16), nullable=False, default='local')
|
||||
|
||||
# Soft on/off so a monitor can be retired without losing policy references.
|
||||
isactive = db.Column(db.Boolean, nullable=False, default=True)
|
||||
|
||||
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('kind', 'displayname', name='uq_endpointmonitors_kinddisplay'),
|
||||
db.Index('idx_endpointmonitors_source', 'source'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EndpointMonitor {self.displayname!r} kind={self.kind} source={self.source}>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'endpointmonitorid': self.endpointmonitorid,
|
||||
'kind': self.kind,
|
||||
'displayname': self.displayname,
|
||||
'processname': self.processname,
|
||||
'servicename': self.servicename,
|
||||
'launchpath': self.launchpath,
|
||||
'datacollectorpath': self.datacollectorpath,
|
||||
'logcollectorpath': self.logcollectorpath,
|
||||
'source': self.source,
|
||||
'isactive': self.isactive,
|
||||
'createdat': _utciso(self.createdat),
|
||||
}
|
||||
82
plugins/endpoints/models/operatorreport.py
Normal file
82
plugins/endpoints/models/operatorreport.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Operator issue reports (screenshot + free-text notes from the tray).
|
||||
|
||||
An operator on a shop-floor PC opens the tray "Report an issue" dialog, writes a
|
||||
free-text description, and optionally attaches a screenshot of their screen. The
|
||||
agent uploads that to ShopDB, where it becomes one OperatorReport row linked to
|
||||
the reporting PC's asset. The screenshot bytes live on disk under the instance
|
||||
uploads dir (like employee photos); this row holds only metadata plus the
|
||||
relative file path so the image can be served back and purged on retention.
|
||||
|
||||
Reports are transient by design: a retention window (default 7 days) purges old
|
||||
rows and their image files, so this is a rolling operator-signal feed, not a
|
||||
permanent record.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
def _utciso(value):
|
||||
"""Naive-UTC datetime -> ISO string marked UTC, or None."""
|
||||
return value.isoformat() + 'Z' if value else None
|
||||
|
||||
|
||||
class OperatorReport(db.Model):
|
||||
"""One operator-submitted issue report for a PC asset."""
|
||||
|
||||
__tablename__ = 'operatorreports'
|
||||
|
||||
operatorreportid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# The PC this report is about. ON DELETE CASCADE: when the asset goes, its
|
||||
# operator reports go with it.
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# The dropdown reason. Only 'Other (free text)' is functional today, but the
|
||||
# column is kept so the richer reason routing can return without a migration.
|
||||
reason = db.Column(db.String(128), nullable=False, default='Other (free text)')
|
||||
|
||||
# The operator's free-text description. Nullable: a screenshot alone is a
|
||||
# valid report.
|
||||
details = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Screenshot metadata. imagefilename is the stored file name under the
|
||||
# operator-reports uploads dir; null when the operator declined a screenshot.
|
||||
imagefilename = db.Column(db.String(255), nullable=True)
|
||||
imagemimetype = db.Column(db.String(64), nullable=True)
|
||||
imagebytesize = db.Column(db.Integer, nullable=True)
|
||||
|
||||
# The hostname the agent reported at upload time, kept for traceability even
|
||||
# if the asset link is later re-pointed.
|
||||
reportedhostname = db.Column(db.String(255), nullable=True)
|
||||
|
||||
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_operatorreports_asset', 'assetid'),
|
||||
db.Index('idx_operatorreports_createdat', 'createdat'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<OperatorReport {self.operatorreportid} asset={self.assetid}>"
|
||||
|
||||
def has_image(self):
|
||||
return bool(self.imagefilename)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'operatorreportid': self.operatorreportid,
|
||||
'assetid': self.assetid,
|
||||
'reason': self.reason,
|
||||
'details': self.details,
|
||||
'hasimage': self.has_image(),
|
||||
'imagemimetype': self.imagemimetype,
|
||||
'imagebytesize': self.imagebytesize,
|
||||
'reportedhostname': self.reportedhostname,
|
||||
'createdat': _utciso(self.createdat),
|
||||
}
|
||||
@@ -167,19 +167,19 @@ class EndpointsPlugin(BasePlugin):
|
||||
# running a staged script or pushing an installer is a strictly
|
||||
# heavier grant than a routine service/process toggle.
|
||||
('endpoints.deploy',
|
||||
'Run staged scripts and push application installers to endpoints', 'endpoints'),
|
||||
# Packaging the agent is a heavier grant than deploying to one, and
|
||||
# it is not the same job either: the generated ZIP carries a live
|
||||
# collector key stamped into its install script, so whoever holds
|
||||
# this can mint a distributable artefact containing the fleet-wide
|
||||
# command-channel credential. It was require_role('admin'), which
|
||||
# this codebase otherwise reserves for the few genuinely
|
||||
# role-shaped checks - and a role cannot be granted to one person
|
||||
# for one job the way a permission can.
|
||||
('endpoints.agentpackage',
|
||||
'Generate and download an agent or adoption-tool package '
|
||||
'(contains a live collector key)', 'endpoints'),
|
||||
# Collector service scope for the endpoint agent. A managed token
|
||||
'Run staged scripts and push application installers to endpoints', 'endpoints'),
|
||||
# Packaging the agent is a heavier grant than deploying to one, and
|
||||
# it is not the same job either: the generated ZIP carries a live
|
||||
# collector key stamped into its install script, so whoever holds
|
||||
# this can mint a distributable artefact containing the fleet-wide
|
||||
# command-channel credential. It was require_role('admin'), which
|
||||
# this codebase otherwise reserves for the few genuinely
|
||||
# role-shaped checks - and a role cannot be granted to one person
|
||||
# for one job the way a permission can.
|
||||
('endpoints.agentpackage',
|
||||
'Generate and download an agent or adoption-tool package '
|
||||
'(contains a live collector key)', 'endpoints'),
|
||||
# Collector service scope for the endpoint agent. A managed token
|
||||
# scoped to ONLY this authorizes the endpoints collector and nothing
|
||||
# else, so rotating/revoking it stops endpoint-agent traffic without
|
||||
# touching computers/geenforce collectors. Minted from the settings
|
||||
@@ -486,6 +486,23 @@ class EndpointsPlugin(BasePlugin):
|
||||
# the installed-software inventory
|
||||
'group': 'software',
|
||||
},
|
||||
{
|
||||
# Operator-submitted issue reports: a free-text note and an
|
||||
# optional screenshot the operator captured from the tray. Sits
|
||||
# in the Monitors tab beside the other watch-and-observe panels.
|
||||
# A custom render carries the thumbnail grid and the expanding
|
||||
# lightbox with left/right cycling the generic modes cannot.
|
||||
'id': 'endpoints-operator-reports',
|
||||
'title': 'Operator Reports',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/panel/{assetid}/operatorreports',
|
||||
'render': 'operatorreports',
|
||||
'imagebase': '/api/endpoints/operatorreports',
|
||||
'collapsible': True,
|
||||
'empty': 'No operator reports for this endpoint.',
|
||||
'position': 18,
|
||||
'group': 'monitors',
|
||||
},
|
||||
]
|
||||
|
||||
def get_dashboard_widgets(self) -> List[Dict]:
|
||||
@@ -802,6 +819,26 @@ class EndpointsPlugin(BasePlugin):
|
||||
|
||||
state.lastreportedat = datetime.utcnow()
|
||||
|
||||
# Warn-only reconciler: on this check-in, compare the monitors site
|
||||
# policy expects to be running against what the snapshot reports, and
|
||||
# cache the drift on the state in THIS transaction (same ledger commit as
|
||||
# servicesjson). No start/stop is issued - drift is surfaced for an
|
||||
# operator to act on. Reads the freshly reported services when present,
|
||||
# else the last-known snapshot, so a report that omits services still
|
||||
# reconciles against what we last saw. Fail-open: a reconciler error must
|
||||
# never fail an otherwise-good check-in, so drift is left unchanged.
|
||||
try:
|
||||
if 'services' in payload:
|
||||
reported_services = payload.get('services') or []
|
||||
else:
|
||||
reported_services = json.loads(state.servicesjson or '[]')
|
||||
from .services.reconciler import compute_drift
|
||||
state.driftjson = _as_json(compute_drift(assetid, reported_services))
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'reconciler failed for asset %s; leaving drift unchanged',
|
||||
assetid)
|
||||
|
||||
# Log any keys the agent sent that the ingest path does not consume yet.
|
||||
# New agent telemetry (disks, bios, memory, ...) lands here first: it is
|
||||
# not persisted until the server grows columns for it, but logging the
|
||||
|
||||
0
plugins/endpoints/services/__init__.py
Normal file
0
plugins/endpoints/services/__init__.py
Normal file
153
plugins/endpoints/services/reconciler.py
Normal file
153
plugins/endpoints/services/reconciler.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Warn-only monitor reconciler.
|
||||
|
||||
The reconciler answers one question on every agent check-in: for the monitors a
|
||||
site policy (or the shipped bundle) attaches to this endpoint, is each one the
|
||||
endpoint is EXPECTED to be running actually reported as running in the latest
|
||||
snapshot? Every monitor that is expected-running but not observed-running is
|
||||
DRIFT, and drift is surfaced to an operator for MANUAL intervention.
|
||||
|
||||
This is warn-only. The reconciler NEVER starts, stops, or enqueues anything - it
|
||||
compares observed state against expected state and records the difference. Whether
|
||||
to later automate remediation (a keep-alive that starts a process when it drifts)
|
||||
is a separate, deliberate opt-in the product does not ship switched on.
|
||||
|
||||
Two seams are kept deliberately narrow so this stays easy to change:
|
||||
|
||||
monitor_expects_running(monitor)
|
||||
The single place that decides "should this monitor be up?". Today every
|
||||
attached monitor is treated as expected-running (attachment implies intent
|
||||
to run). When an observe-only monitor kind arrives - one that only collects
|
||||
general state and must never be flagged as drift - this is the one function
|
||||
that branches on it. Nothing else in the reconciler encodes that policy.
|
||||
|
||||
compute_drift(asset_id, reported_services)
|
||||
Pure given its inputs: it resolves the attached monitors and diffs them
|
||||
against the reported running set. It reads no request state and writes no
|
||||
rows, so the ingest path can call it inside its own transaction and tests
|
||||
can call it directly.
|
||||
"""
|
||||
|
||||
# The running-state vocabulary a watched process/service can report. Kept in
|
||||
# lockstep with plugins/endpoints/api/routes.py::_RUNNING_STATES so a service the
|
||||
# panel paints green is never flagged as drift here. Both read from the same
|
||||
# agent snapshot; disagreeing would show a running app as out-of-desired-state.
|
||||
_RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
|
||||
|
||||
|
||||
def monitor_expects_running(monitor):
|
||||
"""Whether an attached monitor is expected to be running on an endpoint.
|
||||
|
||||
The seam for the "should be up" decision. Today attachment implies intent to
|
||||
run, so every active monitor returns True. A future observe-only monitor kind
|
||||
(general state collection, never keep-alive) branches HERE and returns False
|
||||
so it is never reported as drift - no other part of the reconciler needs to
|
||||
change.
|
||||
"""
|
||||
return bool(getattr(monitor, 'isactive', True))
|
||||
|
||||
|
||||
def _monitor_identity(monitor):
|
||||
"""The (kind, osname) a monitor watches, or None when it has no identity.
|
||||
|
||||
Mirrors how the services panel reads a monitor's OS identity: a process
|
||||
monitor is keyed by its processname, a service monitor by its servicename.
|
||||
"""
|
||||
kind = (getattr(monitor, 'kind', '') or '').lower() or None
|
||||
if kind == 'process':
|
||||
osname = getattr(monitor, 'processname', None)
|
||||
elif kind == 'service':
|
||||
osname = getattr(monitor, 'servicename', None)
|
||||
else:
|
||||
osname = None
|
||||
osname = (osname or '').strip()
|
||||
if not kind or not osname:
|
||||
return None
|
||||
return kind, osname
|
||||
|
||||
|
||||
def _reported_running_keys(reported_services):
|
||||
"""Set of (kind, lowercased-name) the snapshot reports as RUNNING.
|
||||
|
||||
Tolerant of the two shapes the agent snapshot uses: dict entries with
|
||||
name/service and state/status (and an optional kind), or bare strings.
|
||||
Only running entries land in the set; a stopped or unknown entry is absent,
|
||||
which is what makes an expected-running monitor read as drift.
|
||||
"""
|
||||
running = set()
|
||||
for entry in reported_services or ():
|
||||
if isinstance(entry, dict):
|
||||
name = entry.get('name') or entry.get('service') or ''
|
||||
raw = (entry.get('state') or entry.get('status') or '').strip().lower()
|
||||
kind = (entry.get('kind') or '').lower() or None
|
||||
else:
|
||||
name, raw, kind = str(entry), '', None
|
||||
name = name.strip().lower()
|
||||
if not name or raw not in _RUNNING_STATES:
|
||||
continue
|
||||
running.add((kind, name))
|
||||
return running
|
||||
|
||||
|
||||
def _is_running(kind, osname, running_keys):
|
||||
"""Whether a monitor's identity appears running in the snapshot.
|
||||
|
||||
A snapshot entry may omit kind (older agents, or a plain string list), so a
|
||||
match on name alone under an unknown kind still counts as running - the
|
||||
reconciler must not invent drift just because the agent did not label a row.
|
||||
"""
|
||||
needle = osname.strip().lower()
|
||||
if (kind, needle) in running_keys:
|
||||
return True
|
||||
if (None, needle) in running_keys:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def compute_drift(asset_id, reported_services):
|
||||
"""Compute the current drift list for an endpoint. Pure, no writes.
|
||||
|
||||
Resolves the monitors attached to the asset's PC type (shipped defaults plus
|
||||
site policy), keeps only those that are expected-running, and returns one
|
||||
entry per monitor whose identity is not reported as running. Returns an empty
|
||||
list when nothing is attached or nothing has drifted, so the caller can store
|
||||
it as "computed, no drift" distinct from a null "never computed".
|
||||
|
||||
Isolated from Flask request state: it reads the monitor catalog and the
|
||||
passed-in snapshot only, so the ingest path calls it inside its own
|
||||
transaction and tests call it directly.
|
||||
"""
|
||||
try:
|
||||
from plugins.endpoints.api.routes import _resolved_monitors_for_asset
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
monitors = _resolved_monitors_for_asset(asset_id) or []
|
||||
if not monitors:
|
||||
return []
|
||||
|
||||
running_keys = _reported_running_keys(reported_services)
|
||||
|
||||
drift = []
|
||||
seen = set()
|
||||
for monitor in monitors:
|
||||
if not monitor_expects_running(monitor):
|
||||
continue
|
||||
identity = _monitor_identity(monitor)
|
||||
if identity is None:
|
||||
continue
|
||||
kind, osname = identity
|
||||
key = (kind, osname.lower())
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
if _is_running(kind, osname, running_keys):
|
||||
continue
|
||||
drift.append({
|
||||
'monitorid': getattr(monitor, 'endpointmonitorid', None),
|
||||
'kind': kind,
|
||||
'name': osname,
|
||||
'displayname': getattr(monitor, 'displayname', None) or osname,
|
||||
'expected': 'running',
|
||||
'observed': 'notrunning',
|
||||
})
|
||||
return drift
|
||||
Reference in New Issue
Block a user