"""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, Setting, ) from ..models import EndpointMonitor from .routes import endpoints_bp # RETIRED. This setting named the PC type the shipped monitors auto-attached # to, defaulting to 'Shopfloor PC'. That auto-attach path is gone - policy is # now the only attachment route (see _resolve_pctype_monitors) - so nothing # reads this. The key is kept named here, not deleted, so an existing row in a # site's settings table is recognisable as dead rather than mysterious, and so # the key is not reused for something else later. SHIPPED_PCTYPE_SETTING = 'plugin.endpoints.shippedmonitorpctype' _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) 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) # A shipped row is product-owned IDENTITY, not a product-owned opinion about # one site's disk layout. It used to refuse every edit, which meant a plant # whose eDNC lives on D:, or that runs no UDC at all, could neither correct # the launch path nor switch the monitor off - and then got permanent false # drift warnings for it. ADR-015 allows seed data precisely because a site # can edit it afterwards. # # So: kind and displayname are the identity 0007's policy bundle matches on # and stay fixed on a shipped row; the paths and the active flag, which are # what actually differ between plants, are editable. if monitor.source != 'local': for field in ('kind', 'displayname'): if field in body and (body.get(field) or '').strip() != ( getattr(monitor, field) or ''): return error_response( ErrorCodes.FORBIDDEN, 'A shipped monitor keeps its kind and display name; its ' 'paths and active flag can be changed', http_code=403) else: 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, from site policy alone. Policy is the ONLY attachment route. Shipped monitors used to also attach automatically to whichever PC type the shipped-pctype setting named, which defaulted to 'Shopfloor PC' - so eDNC, UDC and MTConnect were expected monitors on every endpoint of that type at every site, with no policy anywhere and no way to see why. 0019 split the shipped bundle into per-monitor policies and removed the shipped SITE policy, intending "off by default"; this branch survived and kept them attached, which is why dncMain.exe kept appearing in Fleet health for all endpoints. Shipped monitors still exist and are still editable - a site composes them through a Site Policy like anything else. What no longer happens is attaching them because a PC type happens to carry a particular name. Returns a list of EndpointMonitor rows (active only), de-duped by id. """ selected = {} 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], })