diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js
index 6bc0f1b3..57fcc970 100644
--- a/frontend/src/api/index.js
+++ b/frontend/src/api/index.js
@@ -178,6 +178,25 @@ export const endpointsApi = {
},
telemetryMeta(assetId) {
return api.get(`/endpoints/asset/${assetId}/telemetrymeta`)
+ },
+ // Monitor catalog: reusable process/service definitions. Shipped rows are
+ // product-owned and read-only; local rows are user-built on the Settings page.
+ monitors: {
+ list() {
+ return api.get('/endpoints/monitors')
+ },
+ create(data) {
+ return api.post('/endpoints/monitors', data)
+ },
+ update(monitorid, data) {
+ return api.put(`/endpoints/monitors/${monitorid}`, data)
+ },
+ remove(monitorid) {
+ return api.delete(`/endpoints/monitors/${monitorid}`)
+ },
+ resolve(pctype) {
+ return api.get('/endpoints/monitors/resolve', { params: { pctype } })
+ }
}
}
diff --git a/plugins/endpoints/api/__init__.py b/plugins/endpoints/api/__init__.py
index 2fc14121..53667058 100644
--- a/plugins/endpoints/api/__init__.py
+++ b/plugins/endpoints/api/__init__.py
@@ -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']
diff --git a/plugins/endpoints/api/monitors.py b/plugins/endpoints/api/monitors.py
new file mode 100644
index 00000000..4e8f7e0e
--- /dev/null
+++ b/plugins/endpoints/api/monitors.py
@@ -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/', 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/', 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],
+ })
diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py
index 087181c2..6085822c 100644
--- a/plugins/endpoints/api/routes.py
+++ b/plugins/endpoints/api/routes.py
@@ -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//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//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.
diff --git a/plugins/endpoints/commands.py b/plugins/endpoints/commands.py
index fdf3f14c..31b05376 100644
--- a/plugins/endpoints/commands.py
+++ b/plugins/endpoints/commands.py
@@ -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:'.
+# 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:)."""
+ 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: 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
+
diff --git a/plugins/endpoints/frontend/routes.js b/plugins/endpoints/frontend/routes.js
index bb9e2d28..0bf67bd9 100644
--- a/plugins/endpoints/frontend/routes.js
+++ b/plugins/endpoints/frontend/routes.js
@@ -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
diff --git a/plugins/endpoints/frontend/views/AgentKeySettings.vue b/plugins/endpoints/frontend/views/AgentKeySettings.vue
index 539a1e04..e3816c1e 100644
--- a/plugins/endpoints/frontend/views/AgentKeySettings.vue
+++ b/plugins/endpoints/frontend/views/AgentKeySettings.vue
@@ -307,7 +307,7 @@
Agent configuration
@@ -319,11 +319,16 @@
stamped into the build above so a deployed agent needs no local edits.
- Coming soon. 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.