236 lines
10 KiB
Python
236 lines
10 KiB
Python
"""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
|
|
|
|
|
|
# Which PC type the shipped monitors attach to, as a SETTING rather than a
|
|
# literal (ADR-015). PC-type vocabulary is per-site seed data: a plant whose
|
|
# type is called "Shop Floor Workstation" got no shipped monitors at all when
|
|
# this was hardcoded, and a plant that happens to use the name for something
|
|
# else got all of them. The default matches the seeded type so nothing changes
|
|
# for a site that uses it; a blank setting attaches the shipped bundle to
|
|
# nothing, which is the right neutral behaviour for a site that does not want
|
|
# it rather than a silent mismatch.
|
|
SHIPPED_PCTYPE_SETTING = 'plugin.endpoints.shippedmonitorpctype'
|
|
SHIPPED_DEFAULT_PCTYPE = 'Shopfloor PC'
|
|
|
|
|
|
def shipped_pctype():
|
|
"""The PC type the shipped monitor bundle attaches to at this site."""
|
|
configured = Setting.get(SHIPPED_PCTYPE_SETTING, default=SHIPPED_DEFAULT_PCTYPE)
|
|
return (configured or '').strip()
|
|
|
|
_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)
|
|
|
|
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/<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 attach to the PC type named by the shipped-pctype setting;
|
|
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 = {}
|
|
|
|
shipped_for = shipped_pctype()
|
|
if shipped_for and pctype == shipped_for:
|
|
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],
|
|
})
|