Add endpoint monitor catalog and warn-only drift reconciler

Operators need to see when a machine's running services or processes fall outside the desired state defined by policy. This adds a reusable monitor catalog (shipped read-only rows plus user-built local rows), computes drift server-side on agent check-in, and surfaces a warning on the endpoint panels. Remediation stays manual for now; automation can follow once demand is proven. Includes migrations 0006-0008 and the monitors API client.
This commit is contained in:
benjamin.baker
2026-09-01 15:44:03 -04:00
parent 1a1bf0640f
commit 42db980ca9
23 changed files with 2054 additions and 51 deletions

View File

@@ -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']

View 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],
})

View File

@@ -87,7 +87,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 +397,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 +444,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 +478,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 +580,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 +598,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 +1465,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.

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View 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>

View 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')

View File

@@ -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})

View 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')

View File

@@ -3,7 +3,9 @@
from .endpoint import (
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
)
from .monitor import EndpointMonitor
__all__ = [
'EndpointState', 'EndpointEvent', 'EndpointCommand', 'EndpointApplication',
'EndpointMonitor',
]

View File

@@ -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).

View 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),
}

View File

@@ -486,26 +486,6 @@ class EndpointsPlugin(BasePlugin):
# the installed-software inventory
'group': 'software',
},
{
# Installed-software (SBOM) inventory. Low-priority reference
# data, not something looked at on every visit, so it starts
# collapsed and sits near the bottom. Raw discovered list for
# now: no managed-application linkage is surfaced yet.
'id': 'endpoints-applications',
'title': 'Installed software',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/applications',
'render': 'table',
'collapsible': True,
'collapsedDefault': True,
'columns': [
{'key': 'displayname', 'label': 'Name'},
{'key': 'displayversion', 'label': 'Version'},
{'key': 'publisher', 'label': 'Publisher'},
],
'empty': 'No installed software reported.',
'position': 30,
},
]
def get_dashboard_widgets(self) -> List[Dict]:
@@ -822,6 +802,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

View File

View 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