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