Bring the wilm branch into main: endpoints plugin, agent, policies, CSV import
Ports the corporate-GitHub wilm branch onto main, keeping the two branches
separate. Thirteen upstream commits arrive with their authorship intact; the
review fixes and the portability work below are on top.
What arrives:
endpoints plugin - a ShopDB agent reporting endpoint state, telemetry,
services and interfaces, with a command channel the agent
long-polls. Ingest stays on the generic collector route
(ADR-006); the agent key is a UI-managed single-scope
token whose revocation is an instant kill switch.
computers policies - Configuration and Business Policies (RMM-style), scope
trees validated at write time, actions checked against
the endpoints command allowlist. Feature-gated: every
route 404s while the setting is off, so enabling it can
never half-expose the surface.
asset CSV import - templates, a collector script carrying the site path, and
a validate-then-commit import. A plugin can own a virtual
column plus a post-write row hook, which is how a PC's
machine number becomes a controls link without a
hand-built relationships sheet.
agent/ - the Windows agent and its installer.
Contract 0.21.0 -> 0.24.0, additive throughout: register_import_hook (0.22),
mint/list/revoke_service_token (0.23), collector_authorized (0.24). The stability
table had no rows for any of them and now does.
MySQL 5.6 portability. The branch stored three policy columns as sa.JSON, the
only JSON columns in the product, and 5.6 has no JSON type: the migration failed
with a bare syntax error near the word JSON. They are TEXT now, through a
JSONText type decorator, so all seventeen call sites still read and write dicts.
The convention elsewhere is already TEXT (guides.blocksjson,
geenforce.manifestjson).
Review fixes carried on top:
- six policy GETs were jwt_required(optional=True), which authenticates a
caller without checking anything; they require computers.view now, and six
redundant jwt_required decorators are gone
- the agent installer took a default BaseUrl and CollectorKey, so a mistyped
run silently pointed an endpoint at the wrong instance; both are mandatory
and a non-https BaseUrl throws unless -AllowInsecureHttp is passed
- a committing CSV import wrote no audit row
Documentation: the 34 new routes are described in docs/api-inventory.json, which
is hand-maintained on purpose, and the generated spec now covers 331 paths and
477 operations across 22 surfaces. DEVELOPMENT-SETUP's 1071 troubleshooting row
said "use MySQL 8 for dev" and now gives the actual fix, including the trap that
5.6 downgrades ROW_FORMAT=DYNAMIC to Compact with a warning instead of an error.
Full suite 2123 passed, vitest 260 passed, all seventeen plugin chains migrate
clean on MySQL 5.6 and the naming, docs, parity and read-surface gates are green.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"""Computers plugin API."""
|
||||
|
||||
from .routes import computers_bp
|
||||
from . import policy_routes # noqa: F401 registers policy routes on computers_bp
|
||||
|
||||
__all__ = ['computers_bp']
|
||||
|
||||
399
plugins/computers/api/policy_routes.py
Normal file
399
plugins/computers/api/policy_routes.py
Normal file
@@ -0,0 +1,399 @@
|
||||
"""CRUD API for Configuration and Business Policies.
|
||||
|
||||
Registered on computers_bp under /api/computers/policies/... Kept in its own
|
||||
module so the whole policy surface can be removed by not importing it - part of
|
||||
the exit gate. Every route first checks the computers_configpolicies_enabled
|
||||
switch and returns 404 while it is off, so enabling/disabling the feature never
|
||||
half-exposes it.
|
||||
|
||||
Write-time validation lives here (not in the model): scope ASTs go through
|
||||
validate_scope, and action names are checked against the endpoints command
|
||||
allowlist, so a bad tree or an unknown action never reaches the database.
|
||||
"""
|
||||
|
||||
from flask import request
|
||||
|
||||
from shopdb.api import (db, AuditLog, success_response, error_response,
|
||||
ErrorCodes, require_permission)
|
||||
|
||||
from .routes import computers_bp
|
||||
from ..models import (ConfigurationPolicy, ConfigurationPolicyAction,
|
||||
BusinessPolicy, BusinessPolicyItem, POLICY_SOURCE_LOCAL,
|
||||
ACTION_MODE_DESIRED, ACTION_MODE_GATE)
|
||||
from ..policyflag import policy_flag_enabled
|
||||
from ..services.policyscope import validate_scope, ScopeError
|
||||
|
||||
_MODES = {ACTION_MODE_DESIRED, ACTION_MODE_GATE}
|
||||
|
||||
|
||||
def _feature_gate():
|
||||
"""Return a 404 response when the policy feature is off, else None."""
|
||||
if not policy_flag_enabled():
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'Policy feature is not enabled', http_code=404)
|
||||
return None
|
||||
|
||||
|
||||
def _known_actions():
|
||||
"""The code-level allowlist of action names a policy may reference."""
|
||||
from plugins.endpoints.commands import COMMAND_ALLOWLIST
|
||||
return set(COMMAND_ALLOWLIST.keys())
|
||||
|
||||
|
||||
def _configpolicy_dict(policy):
|
||||
data = policy.to_dict()
|
||||
data['actions'] = [a.to_dict() for a in policy.actions]
|
||||
return data
|
||||
|
||||
|
||||
def _businesspolicy_dict(policy):
|
||||
data = policy.to_dict()
|
||||
data['items'] = [{
|
||||
'businesspolicyitemid': it.businesspolicyitemid,
|
||||
'configurationpolicyid': it.configurationpolicyid,
|
||||
'sortorder': it.sortorder,
|
||||
'name': it.configurationpolicy.name if it.configurationpolicy else None,
|
||||
} for it in policy.items]
|
||||
return data
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Configuration Policies
|
||||
# =============================================================================
|
||||
|
||||
@computers_bp.route('/policies/configuration', methods=['GET'])
|
||||
@require_permission('computers.view')
|
||||
def list_configuration_policies():
|
||||
gate = _feature_gate()
|
||||
if gate:
|
||||
return gate
|
||||
policies = ConfigurationPolicy.query.order_by(ConfigurationPolicy.name).all()
|
||||
return success_response([_configpolicy_dict(p) for p in policies])
|
||||
|
||||
|
||||
@computers_bp.route('/policies/configuration/<int:policy_id>', methods=['GET'])
|
||||
@require_permission('computers.view')
|
||||
def get_configuration_policy(policy_id: int):
|
||||
gate = _feature_gate()
|
||||
if gate:
|
||||
return gate
|
||||
policy = db.session.get(ConfigurationPolicy, policy_id)
|
||||
if not policy:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'Configuration policy not found', http_code=404)
|
||||
return success_response(_configpolicy_dict(policy))
|
||||
|
||||
|
||||
def _apply_actions(policy, actions):
|
||||
"""Replace a policy's actions from request data, validating each.
|
||||
|
||||
Returns an error_response on failure, else None.
|
||||
"""
|
||||
known = _known_actions()
|
||||
policy.actions.clear()
|
||||
for row in actions or []:
|
||||
name = (row.get('action') or '').strip()
|
||||
if name not in known:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'Unknown action: {name!r}')
|
||||
mode = (row.get('mode') or ACTION_MODE_GATE).strip()
|
||||
if mode not in _MODES:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'Unknown action mode: {mode!r}')
|
||||
policy.actions.append(ConfigurationPolicyAction(
|
||||
action=name,
|
||||
mode=mode,
|
||||
desiredstate=row.get('desiredstate'),
|
||||
params=row.get('params'),
|
||||
intervalminutes=row.get('intervalminutes'),
|
||||
sortorder=row.get('sortorder', 0) or 0,
|
||||
))
|
||||
return None
|
||||
|
||||
|
||||
@computers_bp.route('/policies/configuration', methods=['POST'])
|
||||
@require_permission('computers.create')
|
||||
def create_configuration_policy():
|
||||
gate = _feature_gate()
|
||||
if gate:
|
||||
return gate
|
||||
data = request.get_json() or {}
|
||||
name = (data.get('name') or '').strip()
|
||||
if not name:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
|
||||
if ConfigurationPolicy.query.filter_by(name=name).first():
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
f"Configuration policy '{name}' already exists",
|
||||
http_code=409)
|
||||
|
||||
scope = data.get('scopejson')
|
||||
if scope is not None:
|
||||
try:
|
||||
validate_scope(scope)
|
||||
except ScopeError as exc:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'Invalid scope: {exc}')
|
||||
|
||||
policy = ConfigurationPolicy(
|
||||
name=name,
|
||||
description=data.get('description'),
|
||||
scopejson=scope,
|
||||
source=POLICY_SOURCE_LOCAL,
|
||||
)
|
||||
err = _apply_actions(policy, data.get('actions'))
|
||||
if err:
|
||||
return err
|
||||
|
||||
db.session.add(policy)
|
||||
AuditLog.log('created', 'ConfigurationPolicy', entityname=name)
|
||||
db.session.commit()
|
||||
return success_response(_configpolicy_dict(policy),
|
||||
message='Configuration policy created',
|
||||
http_code=201)
|
||||
|
||||
|
||||
@computers_bp.route('/policies/configuration/<int:policy_id>', methods=['PUT'])
|
||||
@require_permission('computers.edit')
|
||||
def update_configuration_policy(policy_id: int):
|
||||
gate = _feature_gate()
|
||||
if gate:
|
||||
return gate
|
||||
policy = db.session.get(ConfigurationPolicy, policy_id)
|
||||
if not policy:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'Configuration policy not found', http_code=404)
|
||||
if policy.source != POLICY_SOURCE_LOCAL:
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
'Policy is managed externally and is read-only',
|
||||
http_code=409)
|
||||
data = request.get_json() or {}
|
||||
|
||||
if 'name' in data:
|
||||
newname = (data['name'] or '').strip()
|
||||
if newname and newname != policy.name:
|
||||
if ConfigurationPolicy.query.filter_by(name=newname).first():
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
f"Configuration policy '{newname}' already exists",
|
||||
http_code=409)
|
||||
policy.name = newname
|
||||
if 'description' in data:
|
||||
policy.description = data['description']
|
||||
if 'isactive' in data:
|
||||
policy.isactive = data['isactive']
|
||||
if 'scopejson' in data:
|
||||
scope = data['scopejson']
|
||||
if scope is not None:
|
||||
try:
|
||||
validate_scope(scope)
|
||||
except ScopeError as exc:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'Invalid scope: {exc}')
|
||||
policy.scopejson = scope
|
||||
if 'actions' in data:
|
||||
err = _apply_actions(policy, data['actions'])
|
||||
if err:
|
||||
return err
|
||||
|
||||
AuditLog.log('updated', 'ConfigurationPolicy', entityname=policy.name)
|
||||
db.session.commit()
|
||||
return success_response(_configpolicy_dict(policy),
|
||||
message='Configuration policy updated')
|
||||
|
||||
|
||||
@computers_bp.route('/policies/configuration/<int:policy_id>', methods=['DELETE'])
|
||||
@require_permission('computers.delete')
|
||||
def delete_configuration_policy(policy_id: int):
|
||||
gate = _feature_gate()
|
||||
if gate:
|
||||
return gate
|
||||
policy = db.session.get(ConfigurationPolicy, policy_id)
|
||||
if not policy:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'Configuration policy not found', http_code=404)
|
||||
if policy.source != POLICY_SOURCE_LOCAL:
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
'Policy is managed externally and is read-only',
|
||||
http_code=409)
|
||||
inuse = BusinessPolicyItem.query.filter_by(
|
||||
configurationpolicyid=policy_id).count()
|
||||
if inuse:
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f'Cannot delete: used by {inuse} business policy item(s)',
|
||||
http_code=409)
|
||||
db.session.delete(policy)
|
||||
AuditLog.log('deleted', 'ConfigurationPolicy', entityname=policy.name)
|
||||
db.session.commit()
|
||||
return success_response(None, message='Configuration policy deleted')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Business Policies
|
||||
# =============================================================================
|
||||
|
||||
@computers_bp.route('/policies/business', methods=['GET'])
|
||||
@require_permission('computers.view')
|
||||
def list_business_policies():
|
||||
gate = _feature_gate()
|
||||
if gate:
|
||||
return gate
|
||||
policies = (BusinessPolicy.query
|
||||
.order_by(BusinessPolicy.priority, BusinessPolicy.businesspolicyid)
|
||||
.all())
|
||||
return success_response([_businesspolicy_dict(p) for p in policies])
|
||||
|
||||
|
||||
@computers_bp.route('/policies/business/<int:policy_id>', methods=['GET'])
|
||||
@require_permission('computers.view')
|
||||
def get_business_policy(policy_id: int):
|
||||
gate = _feature_gate()
|
||||
if gate:
|
||||
return gate
|
||||
policy = db.session.get(BusinessPolicy, policy_id)
|
||||
if not policy:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'Business policy not found', http_code=404)
|
||||
return success_response(_businesspolicy_dict(policy))
|
||||
|
||||
|
||||
def _apply_items(policy, items):
|
||||
"""Replace a business policy's configuration-policy items, validating each."""
|
||||
policy.items.clear()
|
||||
seen = set()
|
||||
for order, row in enumerate(items or []):
|
||||
cpid = row.get('configurationpolicyid')
|
||||
if cpid is None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'item requires configurationpolicyid')
|
||||
if cpid in seen:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'duplicate configuration policy {cpid}')
|
||||
if not db.session.get(ConfigurationPolicy, cpid):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'unknown configuration policy {cpid}')
|
||||
seen.add(cpid)
|
||||
policy.items.append(BusinessPolicyItem(
|
||||
configurationpolicyid=cpid,
|
||||
sortorder=row.get('sortorder', order),
|
||||
))
|
||||
return None
|
||||
|
||||
|
||||
@computers_bp.route('/policies/business', methods=['POST'])
|
||||
@require_permission('computers.create')
|
||||
def create_business_policy():
|
||||
gate = _feature_gate()
|
||||
if gate:
|
||||
return gate
|
||||
data = request.get_json() or {}
|
||||
name = (data.get('name') or '').strip()
|
||||
if not name:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
|
||||
if BusinessPolicy.query.filter_by(name=name).first():
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
f"Business policy '{name}' already exists",
|
||||
http_code=409)
|
||||
policy = BusinessPolicy(
|
||||
name=name,
|
||||
description=data.get('description'),
|
||||
priority=data.get('priority', 100),
|
||||
source=POLICY_SOURCE_LOCAL,
|
||||
)
|
||||
err = _apply_items(policy, data.get('items'))
|
||||
if err:
|
||||
return err
|
||||
db.session.add(policy)
|
||||
AuditLog.log('created', 'BusinessPolicy', entityname=name)
|
||||
db.session.commit()
|
||||
return success_response(_businesspolicy_dict(policy),
|
||||
message='Business policy created', http_code=201)
|
||||
|
||||
|
||||
@computers_bp.route('/policies/business/<int:policy_id>', methods=['PUT'])
|
||||
@require_permission('computers.edit')
|
||||
def update_business_policy(policy_id: int):
|
||||
gate = _feature_gate()
|
||||
if gate:
|
||||
return gate
|
||||
policy = db.session.get(BusinessPolicy, policy_id)
|
||||
if not policy:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'Business policy not found', http_code=404)
|
||||
if policy.source != POLICY_SOURCE_LOCAL:
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
'Policy is managed externally and is read-only',
|
||||
http_code=409)
|
||||
data = request.get_json() or {}
|
||||
if 'name' in data:
|
||||
newname = (data['name'] or '').strip()
|
||||
if newname and newname != policy.name:
|
||||
if BusinessPolicy.query.filter_by(name=newname).first():
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
f"Business policy '{newname}' already exists",
|
||||
http_code=409)
|
||||
policy.name = newname
|
||||
if 'description' in data:
|
||||
policy.description = data['description']
|
||||
if 'priority' in data:
|
||||
policy.priority = data['priority']
|
||||
if 'isactive' in data:
|
||||
policy.isactive = data['isactive']
|
||||
if 'items' in data:
|
||||
err = _apply_items(policy, data['items'])
|
||||
if err:
|
||||
return err
|
||||
AuditLog.log('updated', 'BusinessPolicy', entityname=policy.name)
|
||||
db.session.commit()
|
||||
return success_response(_businesspolicy_dict(policy),
|
||||
message='Business policy updated')
|
||||
|
||||
|
||||
@computers_bp.route('/policies/business/<int:policy_id>', methods=['DELETE'])
|
||||
@require_permission('computers.delete')
|
||||
def delete_business_policy(policy_id: int):
|
||||
gate = _feature_gate()
|
||||
if gate:
|
||||
return gate
|
||||
policy = db.session.get(BusinessPolicy, policy_id)
|
||||
if not policy:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'Business policy not found', http_code=404)
|
||||
if policy.source != POLICY_SOURCE_LOCAL:
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
'Policy is managed externally and is read-only',
|
||||
http_code=409)
|
||||
db.session.delete(policy)
|
||||
AuditLog.log('deleted', 'BusinessPolicy', entityname=policy.name)
|
||||
db.session.commit()
|
||||
return success_response(None, message='Business policy deleted')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Action catalog (for the UI) and effective-policy preview
|
||||
# =============================================================================
|
||||
|
||||
@computers_bp.route('/policies/actions', methods=['GET'])
|
||||
@require_permission('computers.view')
|
||||
def list_policy_actions():
|
||||
"""The allowlisted action names + labels a policy may reference."""
|
||||
gate = _feature_gate()
|
||||
if gate:
|
||||
return gate
|
||||
from plugins.endpoints.commands import COMMAND_ALLOWLIST
|
||||
return success_response([
|
||||
{'action': name, 'label': meta.get('label'),
|
||||
'description': meta.get('description')}
|
||||
for name, meta in COMMAND_ALLOWLIST.items()
|
||||
])
|
||||
|
||||
|
||||
@computers_bp.route('/policies/effective/<int:asset_id>', methods=['GET'])
|
||||
@require_permission('computers.view')
|
||||
def get_effective_policy(asset_id: int):
|
||||
"""Preview the resolved effective policy for one endpoint (debug/UI)."""
|
||||
gate = _feature_gate()
|
||||
if gate:
|
||||
return gate
|
||||
from ..services.policyresolver import effective_policy_for
|
||||
effective = effective_policy_for(asset_id)
|
||||
return success_response(effective.to_dict())
|
||||
@@ -575,6 +575,9 @@ def create_computer():
|
||||
vendorid=data.get('vendorid'),
|
||||
modelnumberid=data.get('modelnumberid'),
|
||||
loggedinuser=data.get('loggedinuser'),
|
||||
azuredeviceid=data.get('azuredeviceid'),
|
||||
azureobjectid=data.get('azureobjectid'),
|
||||
azuredevicecategory=data.get('azuredevicecategory'),
|
||||
lastreporteddate=data.get('lastreporteddate'),
|
||||
lastboottime=data.get('lastboottime')
|
||||
)
|
||||
@@ -678,8 +681,9 @@ def update_computer(computer_id: int):
|
||||
|
||||
# Update computer fields
|
||||
computer_fields = ['computertypeid', 'hostname', 'osid', 'vendorid',
|
||||
'modelnumberid', 'loggedinuser', 'lastreporteddate',
|
||||
'lastboottime']
|
||||
'modelnumberid', 'loggedinuser', 'azuredeviceid',
|
||||
'azureobjectid', 'azuredevicecategory',
|
||||
'lastreporteddate', 'lastboottime']
|
||||
for key in computer_fields:
|
||||
if key in data:
|
||||
old_val = getattr(comp, key)
|
||||
|
||||
@@ -62,6 +62,18 @@ export default [
|
||||
name: 'access-protocols',
|
||||
component: () => import('./views/AccessProtocolsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/configuration-policies',
|
||||
name: 'configuration-policies',
|
||||
component: () => import('./views/ConfigurationPoliciesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/business-policies',
|
||||
name: 'business-policies',
|
||||
component: () => import('./views/BusinessPoliciesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
223
plugins/computers/frontend/views/BusinessPoliciesList.vue
Normal file
223
plugins/computers/frontend/views/BusinessPoliciesList.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Business Policies</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Business Policy</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Priority</th>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Configuration policies</th>
|
||||
<th>Source</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in policies" :key="p.businesspolicyid">
|
||||
<td>{{ p.priority }}</td>
|
||||
<td>{{ p.name }}</td>
|
||||
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
|
||||
<td>
|
||||
<span v-for="it in p.items" :key="it.businesspolicyitemid" class="badge" style="margin-right:4px;">
|
||||
{{ it.name }}
|
||||
</span>
|
||||
<span v-if="!p.items || p.items.length === 0" style="color: var(--text-light);">none</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="p.source === 'local' ? 'badge-secondary' : 'badge-warning'">
|
||||
{{ p.source }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" :disabled="p.source !== 'local'" @click="openModal(p)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" :disabled="p.source !== 'local'" @click="remove(p)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="policies.length === 0">
|
||||
<td colspan="6" style="text-align:center; color: var(--text-light);">No business policies</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 Business Policy' : 'Add Business Policy' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Name *</label>
|
||||
<input v-model="form.name" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Priority <span class="hint">(lower runs first; first match wins per action)</span></label>
|
||||
<input v-model.number="form.priority" type="number" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea v-model="form.description" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Configuration policies <span class="hint">(evaluated top to bottom)</span></label>
|
||||
<div v-for="(it, index) in form.items" :key="index" class="item-row">
|
||||
<span class="order-badge">{{ index + 1 }}</span>
|
||||
<select v-model.number="it.configurationpolicyid" class="form-control">
|
||||
<option :value="undefined" disabled>Select configuration policy...</option>
|
||||
<option v-for="cp in configPolicies" :key="cp.configurationpolicyid" :value="cp.configurationpolicyid">
|
||||
{{ cp.name }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="index === 0" @click="moveUp(index)">Up</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="index === form.items.length - 1" @click="moveDown(index)">Down</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" @click="form.items.splice(index, 1)">Remove</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="addItem">+ Add configuration policy</button>
|
||||
</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 { computersApi } from '@/api'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const policies = ref([])
|
||||
const configPolicies = 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 { name: '', description: '', priority: 100, items: [] }
|
||||
}
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [biz, config] = await Promise.all([
|
||||
computersApi.policies.business.list(),
|
||||
computersApi.policies.configuration.list()
|
||||
])
|
||||
policies.value = biz.data.data || []
|
||||
configPolicies.value = config.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading business policies:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
error.value = ''
|
||||
if (item) {
|
||||
form.value = {
|
||||
name: item.name || '',
|
||||
description: item.description || '',
|
||||
priority: item.priority ?? 100,
|
||||
items: (item.items || [])
|
||||
.slice()
|
||||
.sort((a, b) => a.sortorder - b.sortorder)
|
||||
.map(it => ({ configurationpolicyid: it.configurationpolicyid }))
|
||||
}
|
||||
} else {
|
||||
form.value = emptyForm()
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
function addItem() {
|
||||
form.value.items.push({ configurationpolicyid: undefined })
|
||||
}
|
||||
|
||||
function moveUp(index) {
|
||||
const items = form.value.items
|
||||
;[items[index - 1], items[index]] = [items[index], items[index - 1]]
|
||||
}
|
||||
|
||||
function moveDown(index) {
|
||||
const items = form.value.items
|
||||
;[items[index + 1], items[index]] = [items[index], items[index + 1]]
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const payload = {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
priority: form.value.priority,
|
||||
items: form.value.items.map((it, order) => ({
|
||||
configurationpolicyid: it.configurationpolicyid,
|
||||
sortorder: order
|
||||
}))
|
||||
}
|
||||
if (editing.value) {
|
||||
await computersApi.policies.business.update(editing.value.businesspolicyid, payload)
|
||||
} else {
|
||||
await computersApi.policies.business.create(payload)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save business policy')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(p) {
|
||||
if (!confirm(`Delete business policy "${p.name}"?`)) return
|
||||
try {
|
||||
await computersApi.policies.business.remove(p.businesspolicyid)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.item-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
|
||||
.order-badge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 24px; height: 24px; border-radius: 12px;
|
||||
background: var(--border-color, #444);
|
||||
}
|
||||
</style>
|
||||
223
plugins/computers/frontend/views/ConfigurationPoliciesList.vue
Normal file
223
plugins/computers/frontend/views/ConfigurationPoliciesList.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Configuration Policies</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Policy</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Actions in policy</th>
|
||||
<th>Source</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in policies" :key="p.configurationpolicyid">
|
||||
<td>{{ p.name }}</td>
|
||||
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
|
||||
<td>
|
||||
<span v-for="a in p.actions" :key="a.action" class="badge" style="margin-right:4px;">
|
||||
{{ a.action }} ({{ a.mode }})
|
||||
</span>
|
||||
<span v-if="!p.actions || p.actions.length === 0" style="color: var(--text-light);">none</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="p.source === 'local' ? 'badge-secondary' : 'badge-warning'">
|
||||
{{ p.source }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" :disabled="p.source !== 'local'" @click="openModal(p)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" :disabled="p.source !== 'local'" @click="remove(p)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="policies.length === 0">
|
||||
<td colspan="5" style="text-align:center; color: var(--text-light);">No configuration policies</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 Configuration Policy' : 'Add Configuration Policy' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Name *</label>
|
||||
<input v-model="form.name" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea v-model="form.description" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Scope <span class="hint">(which endpoints this applies to)</span></label>
|
||||
<div class="scope-builder">
|
||||
<ScopeNodeEditor :node="form.scopejson" :pctypes="pctypes" :apps="apps" :removable="false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Actions</label>
|
||||
<div v-for="(a, index) in form.actions" :key="index" class="action-row">
|
||||
<select v-model="a.action" class="form-control">
|
||||
<option :value="''" disabled>Select action...</option>
|
||||
<option v-for="opt in actionCatalog" :key="opt.action" :value="opt.action">
|
||||
{{ opt.label || opt.action }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="a.mode" class="form-control mode-select">
|
||||
<option value="gate">Allow (gate)</option>
|
||||
<option value="desired">Desired state</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-danger btn-sm" @click="form.actions.splice(index, 1)">Remove</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="addAction">+ Add action</button>
|
||||
</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 { computersApi, applicationsApi } from '@/api'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import ScopeNodeEditor from './ScopeNodeEditor.vue'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const policies = ref([])
|
||||
const pctypes = ref([])
|
||||
const apps = ref([])
|
||||
const actionCatalog = 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 { name: '', description: '', scopejson: { type: 'true' }, actions: [] }
|
||||
}
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [pol, types, appList, actions] = await Promise.all([
|
||||
computersApi.policies.configuration.list(),
|
||||
computersApi.types.list({ perpage: 200 }),
|
||||
applicationsApi.listAll({ perpage: 200 }),
|
||||
computersApi.policies.actions()
|
||||
])
|
||||
policies.value = pol.data.data || []
|
||||
pctypes.value = types.data.data || []
|
||||
// applicationsApi.listAll resolves to a plain array (fetchAllPages), not an
|
||||
// axios response, so read it directly. Reading .data on the array throws and
|
||||
// rejects the whole Promise.all, blanking every dropdown.
|
||||
apps.value = Array.isArray(appList) ? appList : (appList.data?.data || appList.data || [])
|
||||
actionCatalog.value = actions.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading configuration policies:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
error.value = ''
|
||||
if (item) {
|
||||
form.value = {
|
||||
name: item.name || '',
|
||||
description: item.description || '',
|
||||
scopejson: item.scopejson ? JSON.parse(JSON.stringify(item.scopejson)) : { type: 'true' },
|
||||
actions: (item.actions || []).map(a => ({
|
||||
action: a.action, mode: a.mode || 'gate',
|
||||
desiredstate: a.desiredstate, params: a.params, intervalminutes: a.intervalminutes
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
form.value = emptyForm()
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
function addAction() {
|
||||
form.value.actions.push({ action: '', mode: 'gate' })
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const payload = {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
scopejson: form.value.scopejson,
|
||||
actions: form.value.actions
|
||||
}
|
||||
if (editing.value) {
|
||||
await computersApi.policies.configuration.update(editing.value.configurationpolicyid, payload)
|
||||
} else {
|
||||
await computersApi.policies.configuration.create(payload)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save policy')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(p) {
|
||||
if (!confirm(`Delete configuration policy "${p.name}"?`)) return
|
||||
try {
|
||||
await computersApi.policies.configuration.remove(p.configurationpolicyid)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scope-builder {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.action-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
|
||||
.mode-select { max-width: 160px; }
|
||||
</style>
|
||||
@@ -3,6 +3,8 @@
|
||||
<div class="page-header">
|
||||
<h2>Computer Details</h2>
|
||||
<div class="header-actions">
|
||||
<button v-if="canCommand" @click="refreshStatus" class="btn btn-secondary" :disabled="restarting || refreshing">{{ refreshing ? 'Refreshing...' : 'Refresh Status' }}</button>
|
||||
<button v-if="canRestart" @click="confirmRestart" class="btn btn-warning" :disabled="restarting">Restart Computer</button>
|
||||
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
|
||||
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
|
||||
@@ -153,6 +155,28 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entra / Azure device info -->
|
||||
<div
|
||||
v-if="computer.computer?.azuredeviceid || computer.computer?.azureobjectid || computer.computer?.azuredevicecategory"
|
||||
class="section-card"
|
||||
>
|
||||
<h3 class="section-title">Entra Device Info</h3>
|
||||
<div class="info-list">
|
||||
<div v-if="computer.computer?.azuredeviceid" class="info-row">
|
||||
<span class="info-label">Device ID</span>
|
||||
<span class="info-value">{{ computer.computer.azuredeviceid }}</span>
|
||||
</div>
|
||||
<div v-if="computer.computer?.azureobjectid" class="info-row">
|
||||
<span class="info-label">Object ID</span>
|
||||
<span class="info-value">{{ computer.computer.azureobjectid }}</span>
|
||||
</div>
|
||||
<div v-if="computer.computer?.azuredevicecategory" class="info-row">
|
||||
<span class="info-label">Device Category</span>
|
||||
<span class="info-value">{{ computer.computer.azuredevicecategory }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
@@ -226,7 +250,7 @@
|
||||
<CustomFieldsSection :assetid="computer.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<PluginAssetPanels :assetid="computer.assetid" />
|
||||
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" />
|
||||
|
||||
<!-- All relationships (controls, defaultprinter, ...) -->
|
||||
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
|
||||
@@ -253,10 +277,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { colorStyle } from "@/utils/colorStyle"
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computersApi, applicationsApi } from '@/api'
|
||||
import { computersApi, applicationsApi, endpointsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||
@@ -267,15 +291,26 @@ import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const authStore = useAuthStore()
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
const computer = ref(null)
|
||||
const installedApps = ref([])
|
||||
const assetPanels = ref(null)
|
||||
const restarting = ref(false)
|
||||
const refreshing = ref(false)
|
||||
|
||||
// Header endpoint controls only make sense to users who can act on the agent.
|
||||
// Restart is gated separately because it is a destructive grant (endpoints.restart)
|
||||
// enforced again server-side; hiding the button is convenience, not the guard.
|
||||
const canCommand = computed(() => authStore.hasPermission('endpoints.command'))
|
||||
const canRestart = computed(() => authStore.hasPermission('endpoints.restart'))
|
||||
|
||||
// Warranty fetch + hero badge (shared across asset detail pages).
|
||||
const { heroWarranty, warrantyDate: formatWarrantyDate } = useWarrantyBadge(() => computer.value?.assetid)
|
||||
@@ -316,6 +351,108 @@ function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
// Force a live telemetry cycle rather than re-reading the cached snapshot.
|
||||
// Enqueues collect_now (the agent reports off-cadence), waits for the agent to
|
||||
// finish it, then re-fetches the panels so the cards reflect the endpoint's
|
||||
// real current state (e.g. a process the operator just closed).
|
||||
async function refreshStatus() {
|
||||
if (refreshing.value) return
|
||||
refreshing.value = true
|
||||
try {
|
||||
// Baseline the current telemetry timestamp BEFORE forcing a collect. A
|
||||
// command reaching 'succeeded' only means the agent ran collect_now, not
|
||||
// that it has posted the fresh snapshot yet, so we additionally wait for
|
||||
// lastreportedat to move past this baseline before re-rendering the panels.
|
||||
const baseline = await currentTelemetryStamp()
|
||||
const response = await endpointsApi.enqueueCommand(computer.value.assetid, 'collect_now')
|
||||
const commandId = response?.data?.data?.endpointcommandid
|
||||
const outcome = await waitForCommand(commandId)
|
||||
const fresh = outcome === 'succeeded'
|
||||
? await waitForFreshTelemetry(baseline)
|
||||
: false
|
||||
await assetPanels.value?.refresh()
|
||||
if (outcome === 'timeout') {
|
||||
toast.info('Refresh queued; the endpoint agent may be offline or busy')
|
||||
} else if (outcome === 'failed') {
|
||||
toast.error('The endpoint agent could not refresh telemetry')
|
||||
} else if (!fresh) {
|
||||
toast.info('Refresh sent; the endpoint has not reported new data yet')
|
||||
} else {
|
||||
toast.success('Status refreshed')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Failed to refresh status'))
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Read the endpoint's current lastreportedat (ISO string or null). Used as the
|
||||
// freshness baseline; a null baseline means the box has never reported, so any
|
||||
// non-null stamp later counts as fresh.
|
||||
async function currentTelemetryStamp() {
|
||||
try {
|
||||
const response = await endpointsApi.telemetryMeta(computer.value.assetid)
|
||||
return response?.data?.data?.lastreportedat || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Poll lastreportedat until it advances past the baseline (proof a new snapshot
|
||||
// landed) or the attempt budget runs out. Returns true only on a confirmed
|
||||
// newer stamp; a bounded false lets the caller still refresh and message that
|
||||
// the box has not reported yet, rather than hanging the button on an idle agent.
|
||||
async function waitForFreshTelemetry(baseline, { attempts = 15, delayMs = 1000 } = {}) {
|
||||
const baseMs = baseline ? Date.parse(baseline) : 0
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
const stamp = await currentTelemetryStamp()
|
||||
if (stamp && Date.parse(stamp) > baseMs) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Poll the endpoint's recent commands until the given command reaches a terminal
|
||||
// state or we give up. Returns 'succeeded' | 'failed' | 'timeout'. The agent
|
||||
// long-polls, so a live box turns this around in a couple of seconds; an offline
|
||||
// box lets it time out cleanly rather than hanging the button forever.
|
||||
async function waitForCommand(commandId, { attempts = 15, delayMs = 1000 } = {}) {
|
||||
if (!commandId) return 'timeout'
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
try {
|
||||
const response = await endpointsApi.listCommands(computer.value.assetid)
|
||||
const command = (response?.data?.data || []).find(
|
||||
(row) => row.endpointcommandid === commandId)
|
||||
if (command && command.status === 'succeeded') return 'succeeded'
|
||||
if (command && command.status === 'failed') return 'failed'
|
||||
} catch {
|
||||
// Transient poll failure: keep trying until the attempt budget runs out.
|
||||
}
|
||||
}
|
||||
return 'timeout'
|
||||
}
|
||||
|
||||
// Destructive: queues a system_restart that reboots the PC immediately
|
||||
// (shutdown /r /t 0 on the agent). The confirm gate is the last stop before an
|
||||
// unrecoverable action; the server re-checks endpoints.restart regardless.
|
||||
async function confirmRestart() {
|
||||
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
||||
if (!confirm(`Restart ${label} now?\n\n`
|
||||
+ 'The computer reboots immediately with no grace period. '
|
||||
+ 'Any unsaved work on it will be lost.')) return
|
||||
restarting.value = true
|
||||
try {
|
||||
await endpointsApi.enqueueCommand(computer.value.assetid, 'system_restart')
|
||||
toast.success(`Restart queued for ${label}`)
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Failed to queue restart'))
|
||||
} finally {
|
||||
restarting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Retire, not delete: the server deactivates the asset, so the PC
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
>
|
||||
<option value="">Select vendor...</option>
|
||||
<option
|
||||
v-for="v in vendors"
|
||||
v-for="v in filteredVendors"
|
||||
:key="v.vendorid"
|
||||
:value="v.vendorid"
|
||||
>
|
||||
@@ -223,6 +223,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="azuredeviceid">Azure Device ID</label>
|
||||
<input
|
||||
id="azuredeviceid"
|
||||
v-model="form.azuredeviceid"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Entra/Azure deviceId GUID (set by Azure imports)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Remote Access Protocols</label>
|
||||
<div class="protocol-list">
|
||||
@@ -383,6 +394,7 @@ const form = ref({
|
||||
locationid: '',
|
||||
osid: '',
|
||||
loggedinuser: '',
|
||||
azuredeviceid: '',
|
||||
accessmethods: [],
|
||||
notes: '',
|
||||
mapx: null,
|
||||
@@ -432,12 +444,42 @@ watch(() => form.value.serialnumber, (serial) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Filter models by selected vendor and PC type
|
||||
// Ensure a saved model id is present in models.value even when the filtered
|
||||
// list (category PC) excluded it because it has no modeltype. Fetches the
|
||||
// single row by id and appends it; a no-op when already present or blank.
|
||||
async function ensureModelLoaded(modelId) {
|
||||
if (!modelId) return
|
||||
if (models.value.some(m => m.modelnumberid === modelId)) return
|
||||
try {
|
||||
const response = await modelsApi.get(modelId)
|
||||
const model = response.data.data
|
||||
if (model) models.value = [...models.value, model]
|
||||
} catch (e) {
|
||||
// Leave it absent; the select shows blank but we did not invent a value.
|
||||
}
|
||||
}
|
||||
|
||||
// Filter models by selected vendor and PC type. Keep the record's own saved
|
||||
// model even if it is untyped or a different vendor, so editing an existing PC
|
||||
// never renders a blank select that clears the field on save.
|
||||
const filteredModels = computed(() => {
|
||||
// filter by vendor only (PC type now maps to computertypeid, a different id
|
||||
// space than a model's machinetypeid)
|
||||
const savedId = form.value.modelnumberid
|
||||
if (!form.value.vendorid) return models.value
|
||||
return models.value.filter(m => m.vendorid === form.value.vendorid)
|
||||
return models.value.filter(
|
||||
m => m.vendorid === form.value.vendorid || m.modelnumberid === savedId)
|
||||
})
|
||||
|
||||
// Only offer vendors that actually have PC-category models. models is already
|
||||
// scoped to category PC at load, so its vendorids are the PC vendor set. Keep
|
||||
// the record's own vendor even if it falls outside that set, so editing an
|
||||
// existing PC never silently drops its saved vendor.
|
||||
const filteredVendors = computed(() => {
|
||||
const pcVendorIds = new Set(models.value.map(m => m.vendorid))
|
||||
return vendors.value.filter(
|
||||
v => pcVendorIds.has(v.vendorid) || v.vendorid === form.value.vendorid
|
||||
)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -448,8 +490,8 @@ onMounted(async () => {
|
||||
computersApi.types.list({ perpage: 100 }),
|
||||
assetsApi.statuses.list(),
|
||||
vendorsApi.list({ perpage: 100 }),
|
||||
modelsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
locationsApi.list({ perpage: 100 }),
|
||||
modelsApi.listAll({ category: 'PC' }), // backend caps perpage at 100; page through all
|
||||
locationsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
operatingsystemsApi.list({ perpage: 100 }),
|
||||
computersApi.protocols.list(),
|
||||
// Handles its own failure: a site without printers still gets a form.
|
||||
@@ -459,7 +501,7 @@ onMounted(async () => {
|
||||
statuses.value = statusRes.data.data || []
|
||||
vendors.value = vendorRes.data.data || []
|
||||
models.value = allModels
|
||||
locations.value = locRes.data.data || []
|
||||
locations.value = locRes || []
|
||||
operatingsystems.value = osRes.data.data || []
|
||||
protocols.value = protoRes.data.data || []
|
||||
|
||||
@@ -486,6 +528,7 @@ onMounted(async () => {
|
||||
locationid: pc.locationid || '',
|
||||
osid: ext.osid || '',
|
||||
loggedinuser: ext.loggedinuser || '',
|
||||
azuredeviceid: ext.azuredeviceid || '',
|
||||
accessmethods: (pc.accessmethods || []).map(a => ({
|
||||
protocolid: a.protocolid,
|
||||
portoverride: a.portoverride ?? null
|
||||
@@ -496,6 +539,12 @@ onMounted(async () => {
|
||||
levelid: pc.levelid ?? null,
|
||||
ipaddress: primaryComm?.ipaddress || ''
|
||||
}
|
||||
|
||||
// The model list is loaded filtered to category PC, which the backend
|
||||
// inner-joins against ModelType - so an untyped model is absent. If this
|
||||
// PC's saved model is one of those rows, fetch it by id and merge it in,
|
||||
// or its dropdown would render blank and saving would clear the field.
|
||||
await ensureModelLoaded(form.value.modelnumberid)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
@@ -555,6 +604,7 @@ async function savePC() {
|
||||
locationid: form.value.locationid || null,
|
||||
osid: form.value.osid || null,
|
||||
loggedinuser: form.value.loggedinuser || null,
|
||||
azuredeviceid: form.value.azuredeviceid || null,
|
||||
accessmethods: form.value.accessmethods,
|
||||
notes: form.value.notes || null,
|
||||
ipaddress: form.value.ipaddress || null,
|
||||
|
||||
106
plugins/computers/frontend/views/ScopeNodeEditor.vue
Normal file
106
plugins/computers/frontend/views/ScopeNodeEditor.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="scope-node">
|
||||
<div class="scope-node-row">
|
||||
<select v-model="node.type" class="form-control scope-type" @change="onTypeChange">
|
||||
<option value="true">Matches everything</option>
|
||||
<option value="pctype">PC type is</option>
|
||||
<option value="app">App is installed</option>
|
||||
<option value="and">ALL of (AND)</option>
|
||||
<option value="or">ANY of (OR)</option>
|
||||
<option value="not">NOT</option>
|
||||
</select>
|
||||
|
||||
<select v-if="node.type === 'pctype'" v-model.number="node.id" class="form-control">
|
||||
<option :value="undefined" disabled>Select PC type...</option>
|
||||
<option v-for="pt in pctypes" :key="pt.computertypeid" :value="pt.computertypeid">
|
||||
{{ pt.computertype }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<select v-if="node.type === 'app'" v-model.number="node.id" class="form-control">
|
||||
<option :value="undefined" disabled>Select app...</option>
|
||||
<option v-for="app in apps" :key="app.appid" :value="app.appid">
|
||||
{{ app.appname }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<button v-if="removable" type="button" class="btn btn-danger btn-sm" @click="$emit('remove')">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="node.type === 'and' || node.type === 'or'" class="scope-children">
|
||||
<ScopeNodeEditor
|
||||
v-for="(child, index) in node.nodes"
|
||||
:key="index"
|
||||
:node="child"
|
||||
:pctypes="pctypes"
|
||||
:apps="apps"
|
||||
:removable="true"
|
||||
@remove="removeChild(index)"
|
||||
/>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="addChild">+ Add condition</button>
|
||||
</div>
|
||||
|
||||
<div v-if="node.type === 'not'" class="scope-children">
|
||||
<ScopeNodeEditor
|
||||
:node="node.node"
|
||||
:pctypes="pctypes"
|
||||
:apps="apps"
|
||||
:removable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
node: { type: Object, required: true },
|
||||
pctypes: { type: Array, default: () => [] },
|
||||
apps: { type: Array, default: () => [] },
|
||||
removable: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
defineEmits(['remove'])
|
||||
|
||||
function onTypeChange() {
|
||||
const node = props.node
|
||||
// Reset payload keys so a switched node never carries stale fields from its
|
||||
// previous type. Keep only what the new type needs.
|
||||
delete node.id
|
||||
delete node.nodes
|
||||
delete node.node
|
||||
if (node.type === 'and' || node.type === 'or') {
|
||||
node.nodes = [{ type: 'true' }]
|
||||
} else if (node.type === 'not') {
|
||||
node.node = { type: 'true' }
|
||||
}
|
||||
}
|
||||
|
||||
function addChild() {
|
||||
props.node.nodes.push({ type: 'true' })
|
||||
}
|
||||
|
||||
function removeChild(index) {
|
||||
props.node.nodes.splice(index, 1)
|
||||
if (props.node.nodes.length === 0) {
|
||||
props.node.nodes.push({ type: 'true' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
export default { name: 'ScopeNodeEditor' }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scope-node { margin: 4px 0; }
|
||||
.scope-node-row { display: flex; gap: 8px; align-items: center; }
|
||||
.scope-type { max-width: 220px; }
|
||||
.scope-children {
|
||||
margin-left: 24px;
|
||||
padding-left: 12px;
|
||||
border-left: 2px solid var(--border-color, #444);
|
||||
margin-top: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Add azuredeviceid to computers (Entra/Azure device-group imports).
|
||||
|
||||
The core chain created computers WITHOUT an azuredeviceid column. This plugin
|
||||
revision adds it so BOTH fresh installs (core chain builds the table, then this
|
||||
adds the column) and existing installs get it. Guarded/idempotent: skips when
|
||||
the table is absent (plugin disabled) or the column already exists (e.g. a test
|
||||
DB built by db.create_all() from the model).
|
||||
|
||||
Revision ID: computers0002azureid
|
||||
Revises: computers0001anchor
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'computers0002azureid'
|
||||
down_revision = 'computers0001anchor'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'computers' not in insp.get_table_names():
|
||||
return
|
||||
cols = {c['name'] for c in insp.get_columns('computers')}
|
||||
if 'azuredeviceid' not in cols:
|
||||
op.add_column('computers',
|
||||
sa.Column('azuredeviceid', sa.String(length=64), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'computers' not in insp.get_table_names():
|
||||
return
|
||||
cols = {c['name'] for c in insp.get_columns('computers')}
|
||||
if 'azuredeviceid' in cols:
|
||||
op.drop_column('computers', 'azuredeviceid')
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Add azureobjectid and azuredevicecategory to computers.
|
||||
|
||||
The Azure device-group import now also records the Entra directory object id
|
||||
(the CSV 'id' column) and an operator-supplied device category (the group
|
||||
membership label, e.g. SFLD_DYN_Device_Group_...). This revision adds both
|
||||
columns. Guarded/idempotent: skips when the table is absent (plugin disabled)
|
||||
or a column already exists (e.g. a test DB built by db.create_all()).
|
||||
|
||||
Revision ID: computers0003azurefields
|
||||
Revises: computers0002azureid
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'computers0003azurefields'
|
||||
down_revision = 'computers0002azureid'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'computers' not in insp.get_table_names():
|
||||
return
|
||||
cols = {c['name'] for c in insp.get_columns('computers')}
|
||||
if 'azureobjectid' not in cols:
|
||||
op.add_column('computers',
|
||||
sa.Column('azureobjectid', sa.String(length=64), nullable=True))
|
||||
if 'azuredevicecategory' not in cols:
|
||||
op.add_column('computers',
|
||||
sa.Column('azuredevicecategory', sa.String(length=255), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'computers' not in insp.get_table_names():
|
||||
return
|
||||
cols = {c['name'] for c in insp.get_columns('computers')}
|
||||
if 'azuredevicecategory' in cols:
|
||||
op.drop_column('computers', 'azuredevicecategory')
|
||||
if 'azureobjectid' in cols:
|
||||
op.drop_column('computers', 'azureobjectid')
|
||||
162
plugins/computers/migrations/versions/0004_policies.py
Normal file
162
plugins/computers/migrations/versions/0004_policies.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Add Configuration and Business Policy tables (RMM-style policies).
|
||||
|
||||
Creates configurationpolicies, configurationpolicyactions, businesspolicies,
|
||||
and businesspolicyitems. These back the DT-authored policy feature that is gated
|
||||
by the computers_configpolicies_enabled setting; the tables land regardless so
|
||||
the switch can be flipped without a schema change, and the downgrade drops all
|
||||
four for a clean rollback of the whole scope.
|
||||
|
||||
Idempotent: each create_table is skipped when the table already exists (e.g. a
|
||||
test DB built by db.create_all()).
|
||||
|
||||
Revision ID: computers0004policies
|
||||
Revises: computers0003azurefields
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'computers0004policies'
|
||||
down_revision = 'computers0003azurefields'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# TEXT rather than sa.JSON on purpose. MySQL 5.6 has no JSON type - it arrived
|
||||
# in 5.7 - and this migration failed there with a bare syntax error near the
|
||||
# word JSON. Every other JSON-bearing column in the product is TEXT for the same
|
||||
# reason (guides.blocksjson, geenforce.manifestjson); the model reads and writes
|
||||
# dicts through the JSONText type decorator, so nothing above the database
|
||||
# changes.
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
existing = set(insp.get_table_names())
|
||||
|
||||
if 'configurationpolicies' not in existing:
|
||||
op.create_table(
|
||||
'configurationpolicies',
|
||||
sa.Column('configurationpolicyid', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=150), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('scopejson', sa.Text(), nullable=True),
|
||||
sa.Column('source', sa.String(length=50), nullable=False,
|
||||
server_default='local'),
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False,
|
||||
server_default=sa.true()),
|
||||
sa.PrimaryKeyConstraint('configurationpolicyid'),
|
||||
sa.UniqueConstraint('name', name='uq_configurationpolicy_name'),
|
||||
)
|
||||
op.create_index('idx_configpolicy_source', 'configurationpolicies',
|
||||
['source'])
|
||||
op.create_index('idx_configpolicy_active', 'configurationpolicies',
|
||||
['isactive'])
|
||||
|
||||
if 'configurationpolicyactions' not in existing:
|
||||
op.create_table(
|
||||
'configurationpolicyactions',
|
||||
sa.Column('configurationpolicyactionid', sa.Integer(),
|
||||
nullable=False),
|
||||
sa.Column('configurationpolicyid', sa.Integer(), nullable=False),
|
||||
sa.Column('action', sa.String(length=100), nullable=False),
|
||||
sa.Column('mode', sa.String(length=20), nullable=False,
|
||||
server_default='gate'),
|
||||
sa.Column('desiredstate', sa.Text(), nullable=True),
|
||||
sa.Column('params', sa.Text(), nullable=True),
|
||||
sa.Column('intervalminutes', sa.Integer(), nullable=True),
|
||||
sa.Column('sortorder', sa.Integer(), nullable=False,
|
||||
server_default='0'),
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False,
|
||||
server_default=sa.true()),
|
||||
sa.ForeignKeyConstraint(
|
||||
['configurationpolicyid'],
|
||||
['configurationpolicies.configurationpolicyid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('configurationpolicyactionid'),
|
||||
sa.UniqueConstraint('configurationpolicyid', 'action',
|
||||
name='uq_configpolicyaction'),
|
||||
)
|
||||
op.create_index('idx_configpolicyaction_policy',
|
||||
'configurationpolicyactions',
|
||||
['configurationpolicyid'])
|
||||
|
||||
if 'businesspolicies' not in existing:
|
||||
op.create_table(
|
||||
'businesspolicies',
|
||||
sa.Column('businesspolicyid', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=150), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('priority', sa.Integer(), nullable=False,
|
||||
server_default='100'),
|
||||
sa.Column('source', sa.String(length=50), nullable=False,
|
||||
server_default='local'),
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False,
|
||||
server_default=sa.true()),
|
||||
sa.PrimaryKeyConstraint('businesspolicyid'),
|
||||
sa.UniqueConstraint('name', name='uq_businesspolicy_name'),
|
||||
)
|
||||
op.create_index('idx_businesspolicy_priority', 'businesspolicies',
|
||||
['priority'])
|
||||
op.create_index('idx_businesspolicy_active', 'businesspolicies',
|
||||
['isactive'])
|
||||
|
||||
if 'businesspolicyitems' not in existing:
|
||||
op.create_table(
|
||||
'businesspolicyitems',
|
||||
sa.Column('businesspolicyitemid', sa.Integer(), nullable=False),
|
||||
sa.Column('businesspolicyid', sa.Integer(), nullable=False),
|
||||
sa.Column('configurationpolicyid', sa.Integer(), nullable=False),
|
||||
sa.Column('sortorder', sa.Integer(), nullable=False,
|
||||
server_default='0'),
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False,
|
||||
server_default=sa.true()),
|
||||
sa.ForeignKeyConstraint(
|
||||
['businesspolicyid'], ['businesspolicies.businesspolicyid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(
|
||||
['configurationpolicyid'],
|
||||
['configurationpolicies.configurationpolicyid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('businesspolicyitemid'),
|
||||
sa.UniqueConstraint('businesspolicyid', 'configurationpolicyid',
|
||||
name='uq_businesspolicyitem'),
|
||||
)
|
||||
op.create_index('idx_bpitem_business', 'businesspolicyitems',
|
||||
['businesspolicyid'])
|
||||
op.create_index('idx_bpitem_config', 'businesspolicyitems',
|
||||
['configurationpolicyid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
existing = set(insp.get_table_names())
|
||||
|
||||
if 'businesspolicyitems' in existing:
|
||||
op.drop_index('idx_bpitem_config', table_name='businesspolicyitems')
|
||||
op.drop_index('idx_bpitem_business', table_name='businesspolicyitems')
|
||||
op.drop_table('businesspolicyitems')
|
||||
if 'businesspolicies' in existing:
|
||||
op.drop_index('idx_businesspolicy_active', table_name='businesspolicies')
|
||||
op.drop_index('idx_businesspolicy_priority', table_name='businesspolicies')
|
||||
op.drop_table('businesspolicies')
|
||||
if 'configurationpolicyactions' in existing:
|
||||
op.drop_index('idx_configpolicyaction_policy',
|
||||
table_name='configurationpolicyactions')
|
||||
op.drop_table('configurationpolicyactions')
|
||||
if 'configurationpolicies' in existing:
|
||||
op.drop_index('idx_configpolicy_active',
|
||||
table_name='configurationpolicies')
|
||||
op.drop_index('idx_configpolicy_source',
|
||||
table_name='configurationpolicies')
|
||||
op.drop_table('configurationpolicies')
|
||||
@@ -7,6 +7,15 @@ from .computer import (
|
||||
AccessProtocol,
|
||||
ComputerAccess,
|
||||
)
|
||||
from .policy import (
|
||||
ConfigurationPolicy,
|
||||
ConfigurationPolicyAction,
|
||||
BusinessPolicy,
|
||||
BusinessPolicyItem,
|
||||
POLICY_SOURCE_LOCAL,
|
||||
ACTION_MODE_DESIRED,
|
||||
ACTION_MODE_GATE,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'Computer',
|
||||
@@ -14,4 +23,11 @@ __all__ = [
|
||||
'ComputerInstalledApp',
|
||||
'AccessProtocol',
|
||||
'ComputerAccess',
|
||||
'ConfigurationPolicy',
|
||||
'ConfigurationPolicyAction',
|
||||
'BusinessPolicy',
|
||||
'BusinessPolicyItem',
|
||||
'POLICY_SOURCE_LOCAL',
|
||||
'ACTION_MODE_DESIRED',
|
||||
'ACTION_MODE_GATE',
|
||||
]
|
||||
|
||||
@@ -85,6 +85,23 @@ class Computer(BaseModel):
|
||||
lastreporteddate = db.Column(db.DateTime, nullable=True)
|
||||
lastboottime = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Entra/Azure device identity (set by Azure device-group imports)
|
||||
azuredeviceid = db.Column(
|
||||
db.String(64),
|
||||
nullable=True,
|
||||
comment='Entra/Azure deviceId GUID'
|
||||
)
|
||||
azureobjectid = db.Column(
|
||||
db.String(64),
|
||||
nullable=True,
|
||||
comment='Entra/Azure directory object id GUID'
|
||||
)
|
||||
azuredevicecategory = db.Column(
|
||||
db.String(255),
|
||||
nullable=True,
|
||||
comment='Entra device category / group membership label set at import'
|
||||
)
|
||||
|
||||
# Remote access is now modeled per-protocol via the accessmethods
|
||||
# relationship (AccessProtocol / ComputerAccess), replacing isvnc/iswinrm.
|
||||
|
||||
|
||||
44
plugins/computers/models/jsontext.py
Normal file
44
plugins/computers/models/jsontext.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""JSON stored in a TEXT column.
|
||||
|
||||
MySQL 5.6 has no JSON type - it arrived in 5.7 - and a site is still running
|
||||
5.6, where `sa.JSON()` fails at migration time with a bare syntax error near the
|
||||
word JSON. Every other JSON-bearing column in this product is already TEXT for
|
||||
that reason: guides' `blocksjson`, GE-Enforce's `manifestjson`.
|
||||
|
||||
This keeps the Python side identical. A model attribute still holds a dict or a
|
||||
list, still assigns and reads like one; only the storage changes. The
|
||||
alternative - json.dumps and json.loads at seventeen call sites - is the same
|
||||
thing written out seventeen times, with seventeen chances to forget one.
|
||||
|
||||
Note the SQLite behaviour is deliberately the same as MySQL's. Tests run on
|
||||
SQLite and production does not, so a type that behaved differently across the
|
||||
two would hide exactly the failure this exists to prevent.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from sqlalchemy import Text
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
|
||||
|
||||
class JSONText(TypeDecorator):
|
||||
"""A dict or list on the Python side, TEXT in the database."""
|
||||
|
||||
impl = Text
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is None:
|
||||
return None
|
||||
return json.dumps(value, separators=(',', ':'))
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is None or value == '':
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
# A row written before this column was JSON, or hand-edited. Hand
|
||||
# back the raw text rather than raising: a policy that cannot be
|
||||
# parsed should be visible and fixable, not an exception on read.
|
||||
return value
|
||||
198
plugins/computers/models/policy.py
Normal file
198
plugins/computers/models/policy.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Configuration and business policy models (RMM-style).
|
||||
|
||||
DT staff author reusable Configuration Policies (a single rule binding
|
||||
allowlisted endpoint actions to endpoints matched by a boolean scope) and group
|
||||
them into ordered Business Policies assigned to a fleet. When an endpoint reports
|
||||
in, the server folds the matching Business Policies in priority order to compute
|
||||
that endpoint's effective policy (first match wins per action).
|
||||
|
||||
These models live in the computers plugin so policies sit next to ComputerType.
|
||||
The action VOCABULARY stays authoritative in plugins/endpoints/commands.py; this
|
||||
layer references action NAMES only. The `source` column is the seam for a future
|
||||
GE-Enforce sync (local-authored rows vs imported/managed rows).
|
||||
|
||||
The whole surface is gated by the computers_configpolicies_enabled setting; the
|
||||
models register unconditionally (SQLAlchemy needs them), but routes, panels, and
|
||||
enforcement stay inert while the flag is off, and the migration downgrade drops
|
||||
every table for a clean rollback.
|
||||
"""
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
from .jsontext import JSONText
|
||||
|
||||
|
||||
# Provenance of a policy row.
|
||||
# leaves room for `geenforce` (or another manager) to own rows later without a
|
||||
# schema change - an imported row is read-only in the UI and reconciled by its
|
||||
# owner. Kept a plain string, not an enum column, so adding a source is data.
|
||||
POLICY_SOURCE_LOCAL = 'local'
|
||||
|
||||
|
||||
class ConfigurationPolicy(BaseModel):
|
||||
"""A single reusable policy: a scope plus the actions it applies.
|
||||
|
||||
Scope is a boolean expression (AND/OR/NOT over PC types and applications)
|
||||
stored as a small typed JSON AST in scopejson; see services/policyscope.py
|
||||
for the evaluator. Actions live in ConfigurationPolicyAction rows.
|
||||
"""
|
||||
__tablename__ = 'configurationpolicies'
|
||||
|
||||
configurationpolicyid = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(150), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
|
||||
# Boolean target AST: {"type":"and","nodes":[{"type":"pctype","id":3},
|
||||
# {"type":"not","node":{"type":"app","id":12}}]}. Validated server-side
|
||||
# (known node types, depth cap) before it is stored - see policyscope.
|
||||
scopejson = db.Column(JSONText, nullable=True)
|
||||
|
||||
# GE-Enforce interop seam (see POLICY_SOURCE_LOCAL).
|
||||
source = db.Column(db.String(50), default=POLICY_SOURCE_LOCAL, nullable=False)
|
||||
|
||||
# createddate, modifieddate, isactive come from BaseModel.
|
||||
|
||||
actions = db.relationship(
|
||||
'ConfigurationPolicyAction',
|
||||
back_populates='configurationpolicy',
|
||||
cascade='all, delete-orphan',
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_configpolicy_source', 'source'),
|
||||
db.Index('idx_configpolicy_active', 'isactive'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ConfigurationPolicy {self.name}>"
|
||||
|
||||
|
||||
# What a policy action does to a matched endpoint.
|
||||
# desired: reconciled desired-state. desiredstate says what it should be
|
||||
# (e.g. running=true for mtconnect); the server enqueues the
|
||||
# drift-correcting command when telemetry disagrees.
|
||||
# gate: allowlist-gating only. The action is PERMITTED (and shown) on
|
||||
# matched endpoints; it constrains what may be enqueued, it does not
|
||||
# drive anything on its own.
|
||||
ACTION_MODE_DESIRED = 'desired'
|
||||
ACTION_MODE_GATE = 'gate'
|
||||
|
||||
|
||||
class ConfigurationPolicyAction(BaseModel):
|
||||
"""One allowlisted action carried by a ConfigurationPolicy.
|
||||
|
||||
`action` is a name from plugins/endpoints/commands.py COMMAND_ALLOWLIST;
|
||||
it is validated against that allowlist at write time. `mode` selects
|
||||
desired-state reconciliation vs allowlist-gating.
|
||||
"""
|
||||
__tablename__ = 'configurationpolicyactions'
|
||||
|
||||
configurationpolicyactionid = db.Column(db.Integer, primary_key=True)
|
||||
configurationpolicyid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('configurationpolicies.configurationpolicyid',
|
||||
ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Allowlisted action name (endpoints commands.py). Not an FK - the allowlist
|
||||
# is code, not a table - so it is validated in the API layer.
|
||||
action = db.Column(db.String(100), nullable=False)
|
||||
|
||||
mode = db.Column(db.String(20), default=ACTION_MODE_GATE, nullable=False)
|
||||
|
||||
# Desired-state target for mode=desired, as JSON so different actions can
|
||||
# express different targets (e.g. {"running": true}). Null for gate mode.
|
||||
desiredstate = db.Column(JSONText, nullable=True)
|
||||
|
||||
# Optional action parameters passed through to the command (JSON), and an
|
||||
# optional reconcile cadence in minutes (null = reconcile on every report).
|
||||
params = db.Column(JSONText, nullable=True)
|
||||
intervalminutes = db.Column(db.Integer, nullable=True)
|
||||
|
||||
sortorder = db.Column(db.Integer, default=0, nullable=False)
|
||||
|
||||
configurationpolicy = db.relationship(
|
||||
'ConfigurationPolicy', back_populates='actions')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('configurationpolicyid', 'action',
|
||||
name='uq_configpolicyaction'),
|
||||
db.Index('idx_configpolicyaction_policy', 'configurationpolicyid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ConfigurationPolicyAction {self.action} mode={self.mode}>"
|
||||
|
||||
|
||||
class BusinessPolicy(BaseModel):
|
||||
"""An ordered collection of Configuration Policies assigned to a fleet.
|
||||
|
||||
Priority orders Business Policies against each other when several match one
|
||||
endpoint: lower priority number is evaluated first, and the first policy to
|
||||
speak on a given action name wins (first-match-wins per action).
|
||||
"""
|
||||
__tablename__ = 'businesspolicies'
|
||||
|
||||
businesspolicyid = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(150), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
|
||||
# Lower runs first. Ties broken by businesspolicyid for a stable order.
|
||||
priority = db.Column(db.Integer, default=100, nullable=False)
|
||||
|
||||
source = db.Column(db.String(50), default=POLICY_SOURCE_LOCAL, nullable=False)
|
||||
|
||||
# createddate, modifieddate, isactive come from BaseModel.
|
||||
|
||||
items = db.relationship(
|
||||
'BusinessPolicyItem',
|
||||
back_populates='businesspolicy',
|
||||
cascade='all, delete-orphan',
|
||||
order_by='BusinessPolicyItem.sortorder',
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_businesspolicy_priority', 'priority'),
|
||||
db.Index('idx_businesspolicy_active', 'isactive'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<BusinessPolicy {self.name} priority={self.priority}>"
|
||||
|
||||
|
||||
class BusinessPolicyItem(BaseModel):
|
||||
"""Membership of a Configuration Policy in a Business Policy, ordered.
|
||||
|
||||
sortorder sets the walk order WITHIN a Business Policy; combined with the
|
||||
Business Policy priority it gives a fully deterministic fold order for the
|
||||
effective-policy resolver.
|
||||
"""
|
||||
__tablename__ = 'businesspolicyitems'
|
||||
|
||||
businesspolicyitemid = db.Column(db.Integer, primary_key=True)
|
||||
businesspolicyid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('businesspolicies.businesspolicyid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
configurationpolicyid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('configurationpolicies.configurationpolicyid',
|
||||
ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
sortorder = db.Column(db.Integer, default=0, nullable=False)
|
||||
|
||||
businesspolicy = db.relationship('BusinessPolicy', back_populates='items')
|
||||
configurationpolicy = db.relationship('ConfigurationPolicy')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('businesspolicyid', 'configurationpolicyid',
|
||||
name='uq_businesspolicyitem'),
|
||||
db.Index('idx_bpitem_business', 'businesspolicyid'),
|
||||
db.Index('idx_bpitem_config', 'configurationpolicyid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (f"<BusinessPolicyItem bp={self.businesspolicyid} "
|
||||
f"cp={self.configurationpolicyid}>")
|
||||
@@ -12,8 +12,16 @@ from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db, AssetType
|
||||
|
||||
from .models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
|
||||
from .models import (
|
||||
ConfigurationPolicy,
|
||||
ConfigurationPolicyAction,
|
||||
BusinessPolicy,
|
||||
BusinessPolicyItem,
|
||||
)
|
||||
from .api import computers_bp
|
||||
|
||||
from .policyflag import policy_flag_enabled as _policy_flag_enabled
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Marker stamped on PC->measuringtool "controls" links this collector creates
|
||||
@@ -32,6 +40,14 @@ MEASURINGTOOL_LINK_ORIGIN = 'collector:measuringtool'
|
||||
# touched.
|
||||
MACHINE_LINK_ORIGIN = 'collector:machine'
|
||||
|
||||
# Marker stamped on the PC->machine link built from a CSV import's machine
|
||||
# number column. A DISTINCT origin from the collector's so the two provenances
|
||||
# stay separable: an import establishes a link once from a spreadsheet, the
|
||||
# collector maintains it continuously from the floor, and a site may use one,
|
||||
# the other, or both. Same archive discipline - only rows carrying this label
|
||||
# are touched by a re-import.
|
||||
MACHINE_LINK_IMPORT_ORIGIN = 'import:machine'
|
||||
|
||||
# Marker stamped on both links a part-marker PC produces: PC controls marker,
|
||||
# and marker partof the operation it serves. Same archive discipline again.
|
||||
PARTMARKER_LINK_ORIGIN = 'collector:partmarker'
|
||||
@@ -84,12 +100,229 @@ class ComputersPlugin(BasePlugin):
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
"""Return list of SQLAlchemy model classes."""
|
||||
return [Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess]
|
||||
return [Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess,
|
||||
ConfigurationPolicy, ConfigurationPolicyAction, BusinessPolicy, BusinessPolicyItem]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
"""Initialize plugin with Flask app."""
|
||||
self._register_import_hook()
|
||||
logger.info(f"Computers plugin initialized (v{self.meta.version})")
|
||||
|
||||
# -- bulk-import machine link -------------------------------------------
|
||||
|
||||
def _register_import_hook(self):
|
||||
"""Own a `machineassetnumber` column on computers.csv.
|
||||
|
||||
A site with a spreadsheet knows "this PC drives machine 3015" and wants
|
||||
to write that number in the PC row, not hand-build an assetrelationships
|
||||
sheet keyed on ids it never sees. The core importer stays ignorant of
|
||||
what the column means: it accepts the header, keeps it out of the
|
||||
computers write, and hands the value to this hook after each PC row is
|
||||
written, so the PC's asset id exists. Registration is idempotent, so a
|
||||
plugin reload or a test re-init does not double-fire.
|
||||
"""
|
||||
from shopdb.api import register_import_hook
|
||||
|
||||
register_import_hook(
|
||||
'computers',
|
||||
{'machineassetnumber':
|
||||
'the asset number of the machine this PC drives (from '
|
||||
'machines.csv); builds the PC-controls-machine link. A number '
|
||||
'that has no machine, or one already driven by another PC, is '
|
||||
'reported as a warning and skipped - the PC still imports.'},
|
||||
self._import_machine_link)
|
||||
|
||||
def _import_machine_link(self, rowvalues, virtualvalues, line):
|
||||
"""Build a PC->machine controls link from an import row's machine number.
|
||||
|
||||
Runs inside the importer's transaction - a dry run rolls it back - so it
|
||||
reuses the collector's mature _sync_machine_link rather than inventing a
|
||||
second linker, but under the import origin (so an import never archives
|
||||
a collector's rows and vice versa) and in preview mode (so a bulk import
|
||||
never emails an alert per row; the findings are the report's amber
|
||||
warnings instead).
|
||||
|
||||
Returns RowWarning list, never raises: a machine number that resolves to
|
||||
no asset is an amber note beside a PC that still imports, not a failed
|
||||
file. Every warning names the PC, the number, and the one fix.
|
||||
"""
|
||||
from shopdb.api import RowWarning
|
||||
|
||||
machinenumber = (virtualvalues.get('machineassetnumber') or '').strip()
|
||||
if not machinenumber:
|
||||
return []
|
||||
|
||||
assetid = rowvalues.get('assetid')
|
||||
comp = Computer.query.filter_by(assetid=assetid).first() if assetid else None
|
||||
if comp is None:
|
||||
# The PC row was written but its Computer extension is not readable
|
||||
# here - nothing to link to. Amber, not fatal.
|
||||
return [RowWarning(
|
||||
line, 'machineassetnumber',
|
||||
reason='PC row not found to link to machine {!r}'.format(
|
||||
machinenumber),
|
||||
action='re-run the import; if it recurs, check the computers '
|
||||
'row imported',
|
||||
target=machinenumber)]
|
||||
|
||||
hostname = comp.hostname or (assetid and str(assetid)) or '?'
|
||||
|
||||
# IN-FILE FAN-OUT GUARD. Two rows in one computers.csv both naming the
|
||||
# same machine is a spreadsheet mistake - a copied cell, a wrong
|
||||
# workstation number - not a swap. But _incumbent_has_yielded reads a
|
||||
# freshly-imported PC (no lastreporteddate) as having yielded, so
|
||||
# without this the SECOND row would silently archive the first row's
|
||||
# link and steal the machine, and the file would import clean with the
|
||||
# wrong PC on the bay. So the first claim on a machine this run wins and
|
||||
# is remembered; a later row for the same machine is warned and skipped.
|
||||
# The claim set lives on db.session.info, which Flask-SQLAlchemy resets
|
||||
# at request teardown, so it is scoped to exactly this one import and
|
||||
# never leaks into the next.
|
||||
machine = self._asset_by_number(machinenumber)
|
||||
if machine is not None and machine.assetid == assetid:
|
||||
# The number names this very PC. Happens on a PC first seen before
|
||||
# the machine-number convention, whose own asset number IS the
|
||||
# machine number. Skip - a PC does not control itself.
|
||||
return [RowWarning(
|
||||
line, 'machineassetnumber',
|
||||
reason='machine number {!r} is this PC\'s own asset number; '
|
||||
'not linked'.format(machinenumber),
|
||||
action='give this PC a hostname-based asset number, or point '
|
||||
'machineassetnumber at the machine it drives',
|
||||
subject=hostname, target=machinenumber)]
|
||||
|
||||
if machine is not None:
|
||||
# A location-only machine is a virtual bay marker, not a real
|
||||
# machine, so nothing controls it. Skip rather than build a link
|
||||
# that would read as a PC driving a location.
|
||||
skip = self._location_only_skip(
|
||||
machine, hostname, machinenumber, line)
|
||||
if skip is not None:
|
||||
return [skip]
|
||||
|
||||
if machine is not None and machine.assetid != assetid:
|
||||
# ALREADY-CONTROLS-DIFFERENT. Unlike the collector - which reads a
|
||||
# new machine number as a PC moved to a new bay and silently
|
||||
# re-bays it - an import naming a machine when this PC already
|
||||
# actively controls a DIFFERENT one is far more likely a wrong
|
||||
# workstation number in the sheet than a real move. Warn and skip
|
||||
# rather than archive a link a person may have set on purpose.
|
||||
other = self._active_controlled_machine(assetid, machine.assetid)
|
||||
if other is not None:
|
||||
return [RowWarning(
|
||||
line, 'machineassetnumber',
|
||||
reason='{} already controls machine {}; not moved to {}'
|
||||
.format(hostname, other, machine.assetnumber),
|
||||
action='if this PC really moved, clear the existing link '
|
||||
'first; otherwise correct the machine number on '
|
||||
'this row',
|
||||
subject=hostname, target=machinenumber)]
|
||||
|
||||
if machine is not None and machine.assetid != assetid:
|
||||
claimed = db.session.info.setdefault('import_machine_claims', {})
|
||||
# Keyed by machine asset id -> (claimant PC asset id, its hostname).
|
||||
# The id is what the guard compares on (the same PC re-stating its
|
||||
# own link is not a conflict); the hostname is only for the message.
|
||||
firstclaimant = claimed.get(machine.assetid)
|
||||
if firstclaimant is not None and firstclaimant[0] != assetid:
|
||||
return [RowWarning(
|
||||
line, 'machineassetnumber',
|
||||
reason='machine {} was already claimed by {} earlier in '
|
||||
'this import; {} not linked'.format(
|
||||
machine.assetnumber, firstclaimant[1], hostname),
|
||||
action='a machine drives one PC - correct the machine '
|
||||
'number on one of these rows so each PC names its '
|
||||
'own machine',
|
||||
subject=hostname, target=machinenumber)]
|
||||
claimed[machine.assetid] = (assetid, hostname)
|
||||
|
||||
# _sync_machine_link appends plain strings to its warnings list. Collect
|
||||
# them and translate to structured, actionable RowWarnings so the UI can
|
||||
# show the PC, the machine number, and the corrective step.
|
||||
rawwarnings = []
|
||||
self._sync_machine_link(
|
||||
comp, machinenumber, rawwarnings,
|
||||
origin_label=MACHINE_LINK_IMPORT_ORIGIN, preview=True)
|
||||
|
||||
out = []
|
||||
for text in rawwarnings:
|
||||
out.append(RowWarning(
|
||||
line, 'machineassetnumber',
|
||||
reason=text,
|
||||
action=self._machine_link_action(text, machinenumber),
|
||||
subject=hostname, target=machinenumber))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _active_controlled_machine(pcassetid, excludeassetid):
|
||||
"""Asset number of a machine this PC actively controls under the import
|
||||
origin, other than excludeassetid, or None.
|
||||
|
||||
Scoped to MACHINE_LINK_IMPORT_ORIGIN so an import only reasons about its
|
||||
own links: a collector link or a hand-made one is the collector's or a
|
||||
person's to move, not the import's to block on.
|
||||
"""
|
||||
from shopdb.api import AssetRelationship, RelationshipType, Asset
|
||||
|
||||
controls = RelationshipType.query.filter_by(
|
||||
relationshiptype='controls').first()
|
||||
if not controls:
|
||||
return None
|
||||
rel = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == pcassetid,
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
AssetRelationship.label == MACHINE_LINK_IMPORT_ORIGIN,
|
||||
AssetRelationship.targetassetid != excludeassetid,
|
||||
AssetRelationship.isactive.is_(True),
|
||||
).first()
|
||||
if rel is None:
|
||||
return None
|
||||
asset = db.session.get(Asset, rel.targetassetid)
|
||||
return asset.assetnumber if asset else str(rel.targetassetid)
|
||||
|
||||
@staticmethod
|
||||
def _location_only_skip(machine, hostname, machinenumber, line):
|
||||
"""A RowWarning if the resolved asset is a location-only marker, else None.
|
||||
|
||||
A machine flagged islocationonly is a virtual bay, not a real machine,
|
||||
so a PC does not control it. Guarded behind the machines plugin being
|
||||
installed - a site without it cannot have a location-only machine, and
|
||||
the import must not fail because the model is absent.
|
||||
"""
|
||||
from shopdb.api import RowWarning
|
||||
try:
|
||||
from plugins.machines.models import Machine
|
||||
except ImportError:
|
||||
return None
|
||||
row = Machine.query.filter_by(assetid=machine.assetid).first()
|
||||
if row is None or not row.islocationonly:
|
||||
return None
|
||||
return RowWarning(
|
||||
line, 'machineassetnumber',
|
||||
reason='machine {} is a location-only marker, not a real machine; '
|
||||
'not linked'.format(machine.assetnumber),
|
||||
action='point machineassetnumber at a real machine, or clear it '
|
||||
'for this PC',
|
||||
subject=hostname, target=machinenumber)
|
||||
|
||||
@staticmethod
|
||||
def _machine_link_action(text, machinenumber):
|
||||
"""The one corrective step for a machine-link warning, by its shape."""
|
||||
lowered = text.lower()
|
||||
if 'no asset for machine number' in lowered:
|
||||
return ('create the machine asset with number {!r} (import '
|
||||
'machines.csv first), or correct the number on this PC '
|
||||
'row'.format(machinenumber))
|
||||
if 'still held by' in lowered:
|
||||
return ('confirm which PC drives this machine; retire or move the '
|
||||
'old PC off In Use to hand the machine over')
|
||||
if 'taken over from' in lowered:
|
||||
return ('expected on a PC swap; set the old PC to Retired, '
|
||||
'Inventory or In Repair')
|
||||
if 'relationship type missing' in lowered:
|
||||
return 'run flask seed reference-data, then re-import'
|
||||
return 'review this PC and its machine number'
|
||||
|
||||
# -- ADR-006 collector contract -----------------------------------------
|
||||
|
||||
def get_collector_schema(self) -> Optional[Dict]:
|
||||
@@ -173,6 +406,36 @@ class ComputersPlugin(BasePlugin):
|
||||
'site\'s URL and ranges',
|
||||
'position': 26,
|
||||
},
|
||||
] + self._policy_settings_cards()
|
||||
|
||||
def _policy_settings_cards(self):
|
||||
"""Policy admin cards, shown only while the feature flag is on.
|
||||
|
||||
Gating the cards on the switch keeps the settings menu honest: the
|
||||
Configuration/Business Policy screens appear exactly when the feature is
|
||||
enabled and vanish on rollback, matching the 404 the routes return.
|
||||
"""
|
||||
if not self._configpolicies_enabled_safe():
|
||||
return []
|
||||
return [
|
||||
{
|
||||
'group': 'Computers',
|
||||
'to': '/settings/configuration-policies',
|
||||
'icon': 'shield',
|
||||
'title': 'Configuration policies',
|
||||
'description': 'Reusable rules binding endpoint actions to PC '
|
||||
'types and applications',
|
||||
'position': 27,
|
||||
},
|
||||
{
|
||||
'group': 'Computers',
|
||||
'to': '/settings/business-policies',
|
||||
'icon': 'layers',
|
||||
'title': 'Business policies',
|
||||
'description': 'Ordered collections of configuration policies '
|
||||
'assigned by priority',
|
||||
'position': 28,
|
||||
},
|
||||
]
|
||||
|
||||
def get_settings_defaults(self) -> List[dict]:
|
||||
@@ -214,8 +477,41 @@ class ComputersPlugin(BasePlugin):
|
||||
'Off while machine numbers are shared between '
|
||||
'PCs; the collector response still warns.',
|
||||
},
|
||||
{
|
||||
'key': 'computers_configpolicies_enabled',
|
||||
'value': 'false',
|
||||
'valuetype': 'boolean',
|
||||
'category': 'computers',
|
||||
'description': 'Master switch for RMM-style Configuration and '
|
||||
'Business Policies. Off by default: models exist '
|
||||
'but the CRUD routes, WebUI, and endpoint '
|
||||
'enforcement stay inert so the whole feature can '
|
||||
'be rolled back without a schema change.',
|
||||
},
|
||||
]
|
||||
|
||||
def _configpolicies_enabled(self):
|
||||
"""Whether the Configuration/Business Policy feature is turned on.
|
||||
|
||||
Off by default. This is the single exit gate for the policy scope: the
|
||||
tables and models load regardless (SQLAlchemy needs them), but every
|
||||
route, panel, and enforcement path checks this first and no-ops while it
|
||||
is false, so the feature can be disabled without a migration.
|
||||
"""
|
||||
return _policy_flag_enabled()
|
||||
|
||||
def _configpolicies_enabled_safe(self):
|
||||
"""Flag read that never raises when the settings table is absent.
|
||||
|
||||
get_settings_cards can be called in contexts without a database (the
|
||||
plugin contract tests build hooks against a bare app), so a missing
|
||||
settings table must read as "off" rather than propagate.
|
||||
"""
|
||||
try:
|
||||
return _policy_flag_enabled()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _machinelink_alerts_enabled(self):
|
||||
"""Whether the PC-to-machine alerts may be sent.
|
||||
|
||||
@@ -534,7 +830,8 @@ class ComputersPlugin(BasePlugin):
|
||||
'with POST /api/printers/assignments/seed-from-observed.'
|
||||
% ' and '.join(sent))
|
||||
|
||||
def _sync_machine_link(self, comp, machinenumber, warnings):
|
||||
def _sync_machine_link(self, comp, machinenumber, warnings,
|
||||
origin_label=MACHINE_LINK_ORIGIN, preview=False):
|
||||
"""Link a PC to the machine it drives, from the reported machine number.
|
||||
|
||||
Until this existed the machine number was collected and then discarded,
|
||||
@@ -556,6 +853,15 @@ class ComputersPlugin(BasePlugin):
|
||||
cadence, alerting on every pass. The incumbent therefore keeps the
|
||||
machine while it is still alive, and the challenger is recorded as a
|
||||
dormant link. See _incumbent_has_yielded for what alive means.
|
||||
|
||||
origin_label separates provenance: the collector stamps
|
||||
MACHINE_LINK_ORIGIN, a CSV import stamps MACHINE_LINK_IMPORT_ORIGIN, and
|
||||
each only ever archives its own rows so the two sources coexist.
|
||||
|
||||
preview=True is for a dry-run import: DB writes still happen in the
|
||||
caller's transaction (which it rolls back), but the alert fan-out - the
|
||||
one side effect a rollback cannot undo - is suppressed so a preview
|
||||
never emails anyone.
|
||||
"""
|
||||
from shopdb.api import AssetRelationship, RelationshipType, Asset
|
||||
|
||||
@@ -587,7 +893,7 @@ class ComputersPlugin(BasePlugin):
|
||||
mine = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == pcasset.assetid,
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
AssetRelationship.label == MACHINE_LINK_ORIGIN,
|
||||
AssetRelationship.label == origin_label,
|
||||
).all()
|
||||
|
||||
link = None
|
||||
@@ -597,11 +903,23 @@ class ComputersPlugin(BasePlugin):
|
||||
else:
|
||||
rel.isactive = False
|
||||
|
||||
# The unique constraint on assetrelationships is (source, target, type)
|
||||
# and does NOT include label, so an import (import:machine) and the
|
||||
# collector (collector:machine) cannot each hold their OWN row for the
|
||||
# same PC->machine control. If our label-scoped lookup found nothing but
|
||||
# a row for this exact triple exists under the other provenance, adopt
|
||||
# it rather than insert a duplicate that would violate the constraint.
|
||||
if link is None:
|
||||
link = AssetRelationship.query.filter_by(
|
||||
sourceassetid=pcasset.assetid,
|
||||
targetassetid=machine.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid).first()
|
||||
|
||||
# Whoever actively holds this machine now, if it is not this PC.
|
||||
held = AssetRelationship.query.filter(
|
||||
AssetRelationship.targetassetid == machine.assetid,
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
AssetRelationship.label == MACHINE_LINK_ORIGIN,
|
||||
AssetRelationship.label == origin_label,
|
||||
AssetRelationship.sourceassetid != pcasset.assetid,
|
||||
AssetRelationship.isactive.is_(True),
|
||||
).all()
|
||||
@@ -624,9 +942,10 @@ class ComputersPlugin(BasePlugin):
|
||||
sourceassetid=pcasset.assetid,
|
||||
targetassetid=machine.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
label=MACHINE_LINK_ORIGIN,
|
||||
label=origin_label,
|
||||
isactive=False))
|
||||
self._alert_machine_contested(names, comp, machine)
|
||||
if not preview:
|
||||
self._alert_machine_contested(names, comp, machine)
|
||||
else:
|
||||
link.isactive = False
|
||||
return [{'assetid': machine.assetid,
|
||||
@@ -640,7 +959,7 @@ class ComputersPlugin(BasePlugin):
|
||||
sourceassetid=pcasset.assetid,
|
||||
targetassetid=machine.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
label=MACHINE_LINK_ORIGIN,
|
||||
label=origin_label,
|
||||
isactive=True)
|
||||
db.session.add(link)
|
||||
else:
|
||||
@@ -654,7 +973,8 @@ class ComputersPlugin(BasePlugin):
|
||||
warnings.append(
|
||||
'machine {} was taken over from {}; check that PC'.format(
|
||||
machine.assetnumber, oldname))
|
||||
self._alert_pc_superseded(oldname, comp, machine)
|
||||
if not preview:
|
||||
self._alert_pc_superseded(oldname, comp, machine)
|
||||
|
||||
return [{'assetid': machine.assetid,
|
||||
'machinenumber': machine.assetnumber,
|
||||
@@ -757,8 +1077,8 @@ class ComputersPlugin(BasePlugin):
|
||||
#
|
||||
# A row made by hand carries neither label and is deliberately NOT
|
||||
# counted: a person's link is not the collector's to archive.
|
||||
ourlabels = (label, MACHINE_LINK_ORIGIN, MEASURINGTOOL_LINK_ORIGIN,
|
||||
PARTMARKER_LINK_ORIGIN)
|
||||
ourlabels = (label, MACHINE_LINK_ORIGIN, MACHINE_LINK_IMPORT_ORIGIN,
|
||||
MEASURINGTOOL_LINK_ORIGIN, PARTMARKER_LINK_ORIGIN)
|
||||
others = AssetRelationship.query.filter(
|
||||
AssetRelationship.targetassetid == deviceasset.assetid,
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
@@ -1310,15 +1630,17 @@ class ComputersPlugin(BasePlugin):
|
||||
sourceassetid=pcasset.assetid,
|
||||
targetassetid=adopted.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid).first()
|
||||
if claimed is not None and claimed.label == MACHINE_LINK_ORIGIN:
|
||||
if claimed is not None and claimed.label in (
|
||||
MACHINE_LINK_ORIGIN, MACHINE_LINK_IMPORT_ORIGIN):
|
||||
# LEAVE IT ALONE. On a CMM the instrument IS the reported bay,
|
||||
# so the machine sync has already made the row for this exact
|
||||
# triple - and it finds its own rows BY that label. Relabelling
|
||||
# hid the link from it, so the next cycle built a second row for
|
||||
# the same triple and MySQL rejected it: the PC reported 200,
|
||||
# then 500 forever after. The row's lifecycle (including staying
|
||||
# dormant while a bay is contested) belongs to the machine sync;
|
||||
# adoption only needs the identity, which it has.
|
||||
# so the machine sync (or a CSV import's machine link) has
|
||||
# already made the row for this exact triple - and each finds
|
||||
# its own rows BY that label. Relabelling hid the link from it,
|
||||
# so the next cycle built a second row for the same triple and
|
||||
# MySQL rejected it: the PC reported 200, then 500 forever
|
||||
# after. The row's lifecycle (including staying dormant while a
|
||||
# bay is contested) belongs to the machine sync; adoption only
|
||||
# needs the identity, which it has.
|
||||
pass
|
||||
elif claimed is not None:
|
||||
# Relabelling is what adoption MEANS for any other row: the link
|
||||
|
||||
19
plugins/computers/policyflag.py
Normal file
19
plugins/computers/policyflag.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Single source of truth for the Configuration/Business Policy feature gate.
|
||||
|
||||
Kept in its own module (not on the plugin class) so the CRUD routes, the panel
|
||||
surface, and the endpoints enforcement path can all consult the same switch
|
||||
without importing ComputersPlugin. The flag is the computers_configpolicies_enabled
|
||||
Setting row seeded by the plugin's get_settings_defaults; while it is off, every
|
||||
policy code path no-ops, which is the exit gate for rolling the feature back.
|
||||
"""
|
||||
|
||||
|
||||
def policy_flag_enabled():
|
||||
"""True when computers_configpolicies_enabled is set truthy."""
|
||||
from shopdb.api import Setting
|
||||
|
||||
setting = Setting.query.filter_by(
|
||||
key='computers_configpolicies_enabled').first()
|
||||
if not setting:
|
||||
return False
|
||||
return (setting.value or '').strip().lower() in ('true', '1', 'yes')
|
||||
1
plugins/computers/services/__init__.py
Normal file
1
plugins/computers/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Computers plugin services: pure policy logic (scope + resolution)."""
|
||||
168
plugins/computers/services/policyresolver.py
Normal file
168
plugins/computers/services/policyresolver.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Effective-policy resolution: the single swappable policy entry point.
|
||||
|
||||
This module answers one question for the rest of the system: "given an endpoint,
|
||||
what actions apply and how?" It is the boundary the endpoints allowlist seam
|
||||
calls - never the policy tables directly - so the policy ASSEMBLY ENGINE can be
|
||||
swapped without touching enforcement. Today it folds the local computers-plugin
|
||||
tables; a future GE-Enforce integration can replace build_effective_policy (or
|
||||
feed it) and every consumer keeps working unchanged.
|
||||
|
||||
Resolution model (per the design):
|
||||
- Build an endpoint context (PC type + installed appids) from the Computer row.
|
||||
- Walk every active Business Policy in priority order (lower first, ties by id).
|
||||
- Within each, walk its Configuration Policies in sortorder.
|
||||
- A Configuration Policy contributes only if its scope matches the endpoint.
|
||||
- For each action name, FIRST MATCH WINS: once an action is decided it is not
|
||||
overridden by a later, lower-priority policy.
|
||||
|
||||
The result is an EffectivePolicy: a flat, resolved map of action name -> decision
|
||||
(mode desired/gate, plus desiredstate/params), which is exactly what enforcement
|
||||
and the allowlist need.
|
||||
"""
|
||||
|
||||
from .policyscope import evaluate_scope
|
||||
|
||||
|
||||
class EffectiveAction:
|
||||
"""One resolved action decision for an endpoint."""
|
||||
|
||||
__slots__ = ('action', 'mode', 'desiredstate', 'params', 'intervalminutes',
|
||||
'businesspolicyid', 'configurationpolicyid')
|
||||
|
||||
def __init__(self, action, mode, desiredstate, params, intervalminutes,
|
||||
businesspolicyid, configurationpolicyid):
|
||||
self.action = action
|
||||
self.mode = mode
|
||||
self.desiredstate = desiredstate
|
||||
self.params = params
|
||||
self.intervalminutes = intervalminutes
|
||||
self.businesspolicyid = businesspolicyid
|
||||
self.configurationpolicyid = configurationpolicyid
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'action': self.action,
|
||||
'mode': self.mode,
|
||||
'desiredstate': self.desiredstate,
|
||||
'params': self.params,
|
||||
'intervalminutes': self.intervalminutes,
|
||||
'businesspolicyid': self.businesspolicyid,
|
||||
'configurationpolicyid': self.configurationpolicyid,
|
||||
}
|
||||
|
||||
|
||||
class EffectivePolicy:
|
||||
"""The folded, resolved policy for a single endpoint.
|
||||
|
||||
actions maps action name -> EffectiveAction (first-match-wins already
|
||||
applied). Convenience views expose the gate allowlist and desired-state set
|
||||
that enforcement and the endpoints seam consume.
|
||||
"""
|
||||
|
||||
def __init__(self, actions):
|
||||
self.actions = actions
|
||||
|
||||
def allowed_actions(self):
|
||||
"""Set of action names permitted on this endpoint (gate + desired)."""
|
||||
return set(self.actions.keys())
|
||||
|
||||
def desired_actions(self):
|
||||
"""action name -> EffectiveAction for mode == 'desired' only."""
|
||||
return {name: eff for name, eff in self.actions.items()
|
||||
if eff.mode == 'desired'}
|
||||
|
||||
def to_dict(self):
|
||||
return {name: eff.to_dict() for name, eff in self.actions.items()}
|
||||
|
||||
|
||||
def build_endpoint_context(computer):
|
||||
"""Resolve a Computer row into the context the scope evaluator needs.
|
||||
|
||||
Returns {'computertypeid': int|None, 'appids': set[int]}.
|
||||
"""
|
||||
appids = set()
|
||||
for link in getattr(computer, 'installedapps', None) or ():
|
||||
if getattr(link, 'isactive', True) and link.appid is not None:
|
||||
appids.add(link.appid)
|
||||
return {
|
||||
'computertypeid': getattr(computer, 'computertypeid', None),
|
||||
'appids': appids,
|
||||
}
|
||||
|
||||
|
||||
def _iter_matching_configpolicies(business_policies, context):
|
||||
"""Yield (businesspolicyid, ConfigurationPolicy) in deterministic order.
|
||||
|
||||
Business Policies by (priority, businesspolicyid); their items by sortorder.
|
||||
Only active policies whose scope matches the context are yielded.
|
||||
"""
|
||||
ordered_bp = sorted(
|
||||
business_policies,
|
||||
key=lambda bp: (bp.priority, bp.businesspolicyid),
|
||||
)
|
||||
for business_policy in ordered_bp:
|
||||
if not business_policy.isactive:
|
||||
continue
|
||||
for item in sorted(business_policy.items,
|
||||
key=lambda it: (it.sortorder, it.businesspolicyitemid)):
|
||||
config_policy = item.configurationpolicy
|
||||
if config_policy is None or not config_policy.isactive:
|
||||
continue
|
||||
if evaluate_scope(config_policy.scopejson, context):
|
||||
yield business_policy.businesspolicyid, config_policy
|
||||
|
||||
|
||||
def build_effective_policy(computer, business_policies):
|
||||
"""Fold matching policies into an EffectivePolicy for one endpoint.
|
||||
|
||||
computer: the Computer row. business_policies: iterable of BusinessPolicy
|
||||
(with items/configurationpolicy loaded). Pure given its inputs so it stays
|
||||
unit-testable and reusable by an alternate assembly engine.
|
||||
"""
|
||||
context = build_endpoint_context(computer)
|
||||
resolved = {}
|
||||
for businesspolicyid, config_policy in _iter_matching_configpolicies(
|
||||
business_policies, context):
|
||||
for action_row in config_policy.actions:
|
||||
if not action_row.isactive:
|
||||
continue
|
||||
name = action_row.action
|
||||
if name in resolved:
|
||||
# First match wins - a higher-priority policy already decided
|
||||
# this action; later policies do not override it.
|
||||
continue
|
||||
resolved[name] = EffectiveAction(
|
||||
action=name,
|
||||
mode=action_row.mode,
|
||||
desiredstate=action_row.desiredstate,
|
||||
params=action_row.params,
|
||||
intervalminutes=action_row.intervalminutes,
|
||||
businesspolicyid=businesspolicyid,
|
||||
configurationpolicyid=config_policy.configurationpolicyid,
|
||||
)
|
||||
return EffectivePolicy(resolved)
|
||||
|
||||
|
||||
def effective_policy_for(assetid):
|
||||
"""Resolve the effective policy for an endpoint by its assetid.
|
||||
|
||||
This is the public entry point the endpoints plugin calls. It returns an
|
||||
empty EffectivePolicy when the feature is off or the asset is not a known
|
||||
computer, so callers get a safe "no policy" answer without special-casing.
|
||||
|
||||
NOTE for GE-Enforce interop: this function is the seam. To hand policy
|
||||
assembly to GE-Enforce later, replace the body below (or dispatch on source)
|
||||
while keeping this signature and the EffectivePolicy return type intact.
|
||||
"""
|
||||
from ..policyflag import policy_flag_enabled
|
||||
if not policy_flag_enabled():
|
||||
return EffectivePolicy({})
|
||||
|
||||
from ..models import Computer, BusinessPolicy
|
||||
|
||||
computer = Computer.query.filter_by(assetid=assetid).first()
|
||||
if computer is None:
|
||||
return EffectivePolicy({})
|
||||
|
||||
business_policies = BusinessPolicy.query.filter_by(isactive=True).all()
|
||||
return build_effective_policy(computer, business_policies)
|
||||
96
plugins/computers/services/policyscope.py
Normal file
96
plugins/computers/services/policyscope.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Boolean scope evaluation for Configuration Policies.
|
||||
|
||||
A policy's scopejson is a small typed boolean AST over two endpoint dimensions,
|
||||
PC type and installed application. This module is the ONLY place that interprets
|
||||
that AST: it validates the shape (known node types, bounded depth) and evaluates
|
||||
a validated tree against a resolved endpoint context.
|
||||
|
||||
Node grammar (all dict nodes carry a "type"):
|
||||
{"type": "and", "nodes": [<node>, ...]}
|
||||
{"type": "or", "nodes": [<node>, ...]}
|
||||
{"type": "not", "node": <node>}
|
||||
{"type": "pctype", "id": <computertypeid:int>}
|
||||
{"type": "app", "id": <appid:int>}
|
||||
{"type": "true"} # matches everything (an unscoped/global policy)
|
||||
|
||||
Kept pure and dependency-free (no db, no Flask) so it is trivially unit-tested
|
||||
and so a future GE-Enforce assembly engine can reuse the exact same evaluator on
|
||||
the exact same AST when it composes policies.
|
||||
"""
|
||||
|
||||
# Bound the tree so a hand-crafted or imported AST cannot cause pathological
|
||||
# recursion. Ten levels is far past any policy a human would author.
|
||||
MAX_SCOPE_DEPTH = 10
|
||||
|
||||
_LOGICAL = {'and', 'or', 'not'}
|
||||
_LEAF = {'pctype', 'app', 'true'}
|
||||
|
||||
|
||||
class ScopeError(ValueError):
|
||||
"""Raised when a scope AST is malformed. Carries a human-readable reason."""
|
||||
|
||||
|
||||
def validate_scope(node, _depth=0):
|
||||
"""Validate a scope AST, raising ScopeError on any problem.
|
||||
|
||||
Returns None on success. Call this at write time (CRUD) so a bad tree never
|
||||
reaches the database; evaluate_scope then trusts the stored shape.
|
||||
"""
|
||||
if _depth > MAX_SCOPE_DEPTH:
|
||||
raise ScopeError(f'scope nested deeper than {MAX_SCOPE_DEPTH} levels')
|
||||
if not isinstance(node, dict):
|
||||
raise ScopeError('scope node must be an object')
|
||||
|
||||
ntype = node.get('type')
|
||||
if ntype not in _LOGICAL and ntype not in _LEAF:
|
||||
raise ScopeError(f'unknown scope node type: {ntype!r}')
|
||||
|
||||
if ntype in ('and', 'or'):
|
||||
nodes = node.get('nodes')
|
||||
if not isinstance(nodes, list) or not nodes:
|
||||
raise ScopeError(f'{ntype} requires a non-empty "nodes" list')
|
||||
for child in nodes:
|
||||
validate_scope(child, _depth + 1)
|
||||
elif ntype == 'not':
|
||||
child = node.get('node')
|
||||
if child is None:
|
||||
raise ScopeError('not requires a "node"')
|
||||
validate_scope(child, _depth + 1)
|
||||
elif ntype in ('pctype', 'app'):
|
||||
ident = node.get('id')
|
||||
if not isinstance(ident, int) or isinstance(ident, bool):
|
||||
raise ScopeError(f'{ntype} requires an integer "id"')
|
||||
# 'true' carries no payload.
|
||||
|
||||
|
||||
def evaluate_scope(node, context):
|
||||
"""Evaluate a validated scope AST against an endpoint context.
|
||||
|
||||
context is expected to expose:
|
||||
context['computertypeid'] -> int or None
|
||||
context['appids'] -> set/collection of installed appids
|
||||
|
||||
Returns True when the endpoint falls in scope. An empty/None scope means the
|
||||
policy is unscoped and matches everything (same as {"type": "true"}), which
|
||||
keeps a freshly-created policy usable before a scope is drawn.
|
||||
"""
|
||||
if node is None:
|
||||
return True
|
||||
|
||||
ntype = node['type']
|
||||
if ntype == 'true':
|
||||
return True
|
||||
if ntype == 'and':
|
||||
return all(evaluate_scope(child, context) for child in node['nodes'])
|
||||
if ntype == 'or':
|
||||
return any(evaluate_scope(child, context) for child in node['nodes'])
|
||||
if ntype == 'not':
|
||||
return not evaluate_scope(node['node'], context)
|
||||
if ntype == 'pctype':
|
||||
return context.get('computertypeid') == node['id']
|
||||
if ntype == 'app':
|
||||
return node['id'] in (context.get('appids') or ())
|
||||
|
||||
# validate_scope should have caught this; be strict rather than silently
|
||||
# matching an endpoint against an unknown node.
|
||||
raise ScopeError(f'unknown scope node type at evaluation: {ntype!r}')
|
||||
274
plugins/endpoints/AGENT-CONTRACT.md
Normal file
274
plugins/endpoints/AGENT-CONTRACT.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# Endpoint agent contract (C# build reference)
|
||||
|
||||
Companion to DESIGN-DECISIONS-agent.md. This is the concrete wire contract the
|
||||
future C# agent targets, derived from `EndpointsPlugin.get_collector_schema()`
|
||||
and the generic collector dispatcher in `shopdb/core/api/collector.py`. When the
|
||||
schema changes, update this file in the same change.
|
||||
|
||||
## 1. Transport
|
||||
|
||||
- Method/URL: `POST {baseurl}/api/collector/endpoints`
|
||||
- Auth header: `X-API-Key: <collector key>` (Bearer token also accepted). The
|
||||
key is a UI-managed, endpoints-only service token minted from Settings ->
|
||||
Endpoint agent key (section 6). A bootstrap `.env` collector key still works
|
||||
until the first UI key is generated, then it is switched off (section 6). No
|
||||
querystring fallback.
|
||||
- Body: `application/json`, a single flat object (NOT an array). One POST per
|
||||
report per endpoint.
|
||||
- Identity field: `hostname` (required, case-insensitive).
|
||||
|
||||
## 2. Request payload
|
||||
|
||||
Only `hostname` is required. Every other field is optional; the server upserts
|
||||
only the keys that are present, so a lightweight report can omit inventory
|
||||
arrays. `adopt` is first-contact only.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"hostname": "SHOP-PC-014", // required, resolved to computers.hostname
|
||||
"adopt": true, // first run ONLY; delegates PC creation to computers plugin
|
||||
"loggedinuser": "j.smith",
|
||||
"serialnumber": "5CD1234ABC", // adoption subset (forwarded to computers)
|
||||
"pctype": "CNC Controller", // adoption subset
|
||||
"machinenumber": "12345", // adoption subset; "" or "9999" == none
|
||||
"agentversion": "1.0.0",
|
||||
"lastboottime": "2025-01-14T06:12:00Z", // ISO-8601 UTC
|
||||
"uptimeseconds": 86400,
|
||||
"idleseconds": 120,
|
||||
"cpupercent": 12.5,
|
||||
"memorypercent": 47.0,
|
||||
"services": [ /* opaque array, stored as JSON */ ],
|
||||
"ports": [ /* opaque array */ ],
|
||||
"interfaces": [
|
||||
{
|
||||
"alias": "Intel(R) Ethernet Connection", // adapter description
|
||||
"ipv4": "10.20.30.40", // "" if none
|
||||
"mac": "AA:BB:CC:DD:EE:FF",
|
||||
"gateway": "10.20.30.1", // "" for the static machine-IPS NIC
|
||||
"subnet": "255.255.255.0",
|
||||
"dhcp": false
|
||||
}
|
||||
],
|
||||
"scheduledtasks": [ /* opaque array */ ],
|
||||
"events": [
|
||||
{
|
||||
"eventid": 6008, // required per item; non-catalog IDs are dropped, not errored
|
||||
"eventchannel": "System",
|
||||
"eventsource": "EventLog",
|
||||
"eventtime": "2025-01-14T06:11:58Z",
|
||||
"eventmessage": "The previous system shutdown was unexpected.",
|
||||
"fields": { "any": "extra" } // opaque object, stored as JSON
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `adopt=true` is sent ONLY by the installer's first run. Steady-state reports
|
||||
omit it (or send `false`). After adoption, an unknown hostname is a hard
|
||||
validation error again (DESIGN-DECISIONS-agent.md 1a).
|
||||
- The adoption subset (`serialnumber`, `pctype`, `machinenumber`, plus
|
||||
`loggedinuser`) is forwarded to the `computers` collector so the newly created
|
||||
PC is not a bare shell. Send them on first contact.
|
||||
- `services`/`ports`/`interfaces`/`scheduledtasks` are stored verbatim as JSON;
|
||||
the server does not validate their inner shape yet. Keep them stable so the UI
|
||||
can render them later. `interfaces` reports EVERY hardware NIC (not just the
|
||||
primary) so the gatewayless static NIC that links a PC to its machine's IPS is
|
||||
preserved (DESIGN-DECISIONS-agent.md 9.3); each entry is
|
||||
`{alias, ipv4, mac, gateway, subnet, dhcp}`.
|
||||
- `machinenumber` is discovered on-box by the agent from the eDNC registry
|
||||
(`HKLM\SOFTWARE\[WOW6432Node\]GE Aircraft Engines\DNC\General:MachineNo`) or
|
||||
the UDC `udc_settings_*.json` (`GeneralSettings.MachineNumber`), in that order
|
||||
(DESIGN-DECISIONS-agent.md 9.4/9.5). `""` or `"9999"` means none.
|
||||
|
||||
## 3. Response
|
||||
|
||||
Success (HTTP 200), ADR-006 envelope:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"status": "ok",
|
||||
"action": "created", // "created" first time, "updated" thereafter
|
||||
"assetid": 4821,
|
||||
"identityvalue": "SHOP-PC-014",
|
||||
"warnings": [
|
||||
"adopted new computer for hostname: 'SHOP-PC-014'" // present only on adoption
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Validation failure (HTTP 400) - e.g. unknown hostname with `adopt` absent:
|
||||
|
||||
```jsonc
|
||||
{ "status": "error", "error": { "code": "VALIDATION_ERROR",
|
||||
"message": "could not resolve hostname to a computer: 'SHOP-PC-014'" } }
|
||||
```
|
||||
|
||||
Agent handling: treat non-200 as retryable except a `VALIDATION_ERROR` that
|
||||
names an unresolved hostname on a NON-first-contact report - that means the PC
|
||||
was removed server-side; surface it, do not spin.
|
||||
|
||||
## 4. C# DTOs
|
||||
|
||||
Match the JSON keys EXACTLY (lowercase, no underscores) to satisfy the naming
|
||||
rule; use attributes so C# properties can stay PascalCase without a camelCase
|
||||
serializer changing the wire keys.
|
||||
|
||||
```csharp
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
public sealed class EndpointReport
|
||||
{
|
||||
[JsonPropertyName("hostname")] public string Hostname { get; set; } = "";
|
||||
[JsonPropertyName("adopt")] public bool? Adopt { get; set; }
|
||||
[JsonPropertyName("loggedinuser")] public string? LoggedInUser { get; set; }
|
||||
[JsonPropertyName("serialnumber")] public string? SerialNumber { get; set; }
|
||||
[JsonPropertyName("pctype")] public string? PcType { get; set; }
|
||||
[JsonPropertyName("machinenumber")] public string? MachineNumber { get; set; }
|
||||
[JsonPropertyName("agentversion")] public string? AgentVersion { get; set; }
|
||||
[JsonPropertyName("lastboottime")] public DateTimeOffset? LastBootTime { get; set; }
|
||||
[JsonPropertyName("uptimeseconds")] public long? UptimeSeconds { get; set; }
|
||||
[JsonPropertyName("idleseconds")] public long? IdleSeconds { get; set; }
|
||||
[JsonPropertyName("cpupercent")] public double? CpuPercent { get; set; }
|
||||
[JsonPropertyName("memorypercent")] public double? MemoryPercent { get; set; }
|
||||
[JsonPropertyName("services")] public object[]? Services { get; set; }
|
||||
[JsonPropertyName("ports")] public object[]? Ports { get; set; }
|
||||
[JsonPropertyName("interfaces")] public object[]? Interfaces { get; set; }
|
||||
[JsonPropertyName("scheduledtasks")]public object[]? ScheduledTasks { get; set; }
|
||||
[JsonPropertyName("events")] public EndpointEventRecord[]? Events { get; set; }
|
||||
}
|
||||
|
||||
public sealed class EndpointEventRecord
|
||||
{
|
||||
[JsonPropertyName("eventid")] public int EventId { get; set; }
|
||||
[JsonPropertyName("eventchannel")] public string? EventChannel { get; set; }
|
||||
[JsonPropertyName("eventsource")] public string? EventSource { get; set; }
|
||||
[JsonPropertyName("eventtime")] public DateTimeOffset? EventTime { get; set; }
|
||||
[JsonPropertyName("eventmessage")] public string? EventMessage { get; set; }
|
||||
[JsonPropertyName("fields")] public Dictionary<string, object>? Fields { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CollectorResponse
|
||||
{
|
||||
[JsonPropertyName("status")] public string Status { get; set; } = "";
|
||||
[JsonPropertyName("action")] public string? Action { get; set; }
|
||||
[JsonPropertyName("assetid")] public int? AssetId { get; set; }
|
||||
[JsonPropertyName("identityvalue")] public string? IdentityValue { get; set; }
|
||||
[JsonPropertyName("warnings")] public string[]? Warnings { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
Serializer settings: `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull`
|
||||
so omitted fields are not sent (server upserts only present keys). Send UTC ISO-8601.
|
||||
|
||||
## 5. "Build site agent" settings flow (build cadence)
|
||||
|
||||
Goal: an operator in ShopDB Settings produces an instance-locked `.exe` that
|
||||
knows its server URL and its collector key, needing no manual config on the
|
||||
client.
|
||||
|
||||
1. Settings -> "Build site agent": operator picks/creates a collector-scoped
|
||||
managed token (the same token accepted by `X-API-Key` on
|
||||
`/api/collector/endpoints`). This binds the build to one ShopDB instance.
|
||||
2. Server stamps a small config (base URL + token + reporting cadence) into the
|
||||
agent build artifact. Instance-locked = the token only works against that
|
||||
server, and the server can revoke it to kill a leaked build.
|
||||
3. First run on a client:
|
||||
- Collect the ShopDB PowerShell asset-collector data set (minus the CSV
|
||||
emit): hostname, logged-in user, serial number, pc type, machine number,
|
||||
boot time, plus the curated event tail.
|
||||
- POST once with `adopt=true`. The endpoints plugin delegates PC creation to
|
||||
the `computers` collector, then writes the first `EndpointState` in the
|
||||
same commit. The endpoint appears in the Fleet UI immediately - no second
|
||||
call, no separate enrollment endpoint.
|
||||
4. Subsequent runs POST on the configured cadence with `adopt` omitted; strict
|
||||
hostname resolution applies from then on.
|
||||
|
||||
Open item (from DESIGN-DECISIONS-agent.md 1a): whether `adopt` stays a
|
||||
per-report flag or is promoted to a site setting (strict vs self-adopting
|
||||
fleet). Default proposed: agent sends `adopt=true` on first run only.
|
||||
|
||||
## 6. UI-managed agent key (kill switch)
|
||||
|
||||
The endpoint agent authenticates with a single UI-managed key scoped to ONLY
|
||||
`collector.ingest.endpoints`, so rotating or revoking it stops all
|
||||
endpoint-agent traffic without affecting the computers or geenforce collectors.
|
||||
|
||||
- Settings -> Endpoint agent key (admin only) generates the key. The full secret
|
||||
is shown ONCE at generation and stored only as a hash afterwards; configure it
|
||||
as the agent `X-API-Key`.
|
||||
- Generating a key is a hard cutover: any previous endpoint key is revoked in the
|
||||
same request, and the bootstrap `.env` fallback for endpoints is switched off
|
||||
(`plugin.endpoints.envkeyfallback` -> false), so the managed key becomes the
|
||||
sole credential.
|
||||
- Regenerate to rotate (old key dies immediately). Revoke to stop all
|
||||
endpoint-agent reporting until a new key is generated - the kill switch for a
|
||||
leaked agent.
|
||||
- Backend: `GET/POST /api/endpoints/agentkey`, `POST
|
||||
/api/endpoints/agentkey/regenerate`, `DELETE /api/endpoints/agentkey`. These
|
||||
use the `mint_service_token`/`list_service_tokens`/`revoke_service_tokens`
|
||||
contract helpers (shopdb.api, contract 0.22.0).
|
||||
|
||||
The bootstrap `.env` key (`COLLECTOR_API_KEY[_ENDPOINTS]`) remains only as a
|
||||
first-run path before a UI key exists; it cannot be the runtime kill switch
|
||||
because container env is injected at boot and cannot be rotated live.
|
||||
|
||||
## 7. Command channel (WebUI -> agent)
|
||||
|
||||
The WebUI can send an endpoint an ALLOWLISTED command (never an arbitrary
|
||||
command string). Delivery is agent-initiated long-poll, so shop-floor PCs open
|
||||
no inbound port: the agent makes an outbound request the server holds open until
|
||||
a command is queued, runs it locally, and posts the result back. All three
|
||||
routes reuse the same endpoints collector key (section 6) via `X-API-Key`, so
|
||||
the command channel shares the kill switch and adds no second secret.
|
||||
|
||||
Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
|
||||
|
||||
- `mtconnect_start` - run the MTConnect UDC agent start script
|
||||
(`C:\ProgramData\UDC\MTConnect_UDC\Agent\run.bat`).
|
||||
- `mtconnect_stop` - stop the running MTConnect UDC agent process.
|
||||
- `mtconnect_status` - report whether the MTConnect UDC agent is running.
|
||||
|
||||
### 7.1 Claim the next command (long-poll)
|
||||
|
||||
`GET {baseurl}/api/endpoints/commands/next?hostname=<hostname>`
|
||||
|
||||
- Auth: `X-API-Key` (endpoints collector key).
|
||||
- The server holds the request open up to ~25s. If a command is queued it is
|
||||
atomically claimed (marked running) and returned; only one poll ever claims a
|
||||
given command.
|
||||
- Command queued (HTTP 200):
|
||||
|
||||
```jsonc
|
||||
{ "status": "ok", "data": { "endpointcommandid": 91, "action": "mtconnect_start" } }
|
||||
```
|
||||
|
||||
- Nothing queued within the hold window: HTTP 204 (no body). Re-poll.
|
||||
|
||||
### 7.2 Report a command result
|
||||
|
||||
`POST {baseurl}/api/endpoints/commands/<endpointcommandid>/result`
|
||||
|
||||
- Auth: `X-API-Key` (endpoints collector key).
|
||||
- Body:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"succeeded": true, // required; false marks the command failed
|
||||
"message": "MTConnect started", // short summary, optional (<= 2000 chars)
|
||||
"mtconnectrunning": true, // optional; observed run state after the action
|
||||
"detail": { "any": "extra" } // optional structured detail, stored as JSON
|
||||
}
|
||||
```
|
||||
|
||||
- When `mtconnectrunning` is present the server caches it on the endpoint's
|
||||
`EndpointState` (`mtconnectrunning` + `mtconnectcheckedat`) so the WebUI toggle
|
||||
shows a real state between polls. Every `mtconnect_*` action SHOULD probe and
|
||||
report the resulting run state so the toggle never lies.
|
||||
|
||||
### 7.3 Enqueue (WebUI side, for reference)
|
||||
|
||||
`POST /api/endpoints/<assetid>/commands` with `{ "action": "mtconnect_start" }`,
|
||||
gated by user JWT + `endpoints.command`. Only one command may be in flight per
|
||||
endpoint (a second enqueue returns HTTP 409 until the first finalizes).
|
||||
|
||||
443
plugins/endpoints/DESIGN-DECISIONS-agent.md
Normal file
443
plugins/endpoints/DESIGN-DECISIONS-agent.md
Normal file
@@ -0,0 +1,443 @@
|
||||
# Agent Design Decisions (running preference list)
|
||||
|
||||
Server-side decisions made while building the `endpoints` plugin that the
|
||||
future C# agent (and the ServiceNow ticketing surface) must honor. This is a
|
||||
living document: add to it as the agent build raises questions. ASCII only, no
|
||||
em-dashes, no emojis, per repo style.
|
||||
|
||||
## Scope of this document
|
||||
|
||||
The `endpoints` plugin (server) is the authoritative contract. The C# agent is
|
||||
a separate deliverable in its own solution/repo. Anything the agent sends must
|
||||
match what this plugin ingests; when the two disagree, this plugin wins and the
|
||||
agent changes.
|
||||
|
||||
## 1. Transport and identity
|
||||
|
||||
- Agent posts one JSON object per host to `POST /api/collector/endpoints`
|
||||
(the generic ADR-006 collector route, auto-registered by the core loader).
|
||||
- Auth is a collector-scoped credential in the request HEADER, not a user JWT
|
||||
and not a querystring. Server reads `COLLECTOR_API_KEY_ENDPOINTS` first, then
|
||||
falls back to `COLLECTOR_API_KEY`. Provision the key the same way the
|
||||
computers reporter does: out of band, e.g. `HKLM:\SOFTWARE\GE\ShopDB
|
||||
CollectorKey`. The key is never baked into a distributed script/binary.
|
||||
- Identity field is `hostname` (case-insensitive). The server resolves it to an
|
||||
existing computer asset via the `computers` plugin. In STEADY STATE an unknown
|
||||
hostname is REJECTED with a validation error, never auto-created by this
|
||||
plugin. Decision: the fleet is provisioned in ShopDB first (imports/Azure
|
||||
device sync) OR adopted at first contact via the delegation shim in section
|
||||
1a; the `endpoints` plugin itself does not get to invent assets.
|
||||
|
||||
## 1a. First-contact adoption (delegation shim, not a design reversal)
|
||||
|
||||
The agent installer's first run reaches a PC that may not exist in ShopDB yet.
|
||||
Rather than letting `endpoints` create assets (which would break ADR-001 and the
|
||||
section 1 rule that this plugin never invents assets), first contact DELEGATES
|
||||
adoption to the `computers` collector, which is already the sole authoritative
|
||||
creator of computer Assets and PC rows.
|
||||
|
||||
Decision:
|
||||
|
||||
- The `endpoints` plugin NEVER constructs an `Asset` or a `Computer` row itself.
|
||||
Section 1 stands. This is a clarification, not a reversal.
|
||||
- When `apply_collector_payload` cannot resolve the hostname AND the payload
|
||||
opts in to adoption (see the `adopt` flag below), the plugin calls the
|
||||
`computers` plugin's `apply_collector_payload` with the PC-identifying subset
|
||||
of the payload, flushes, then re-resolves the hostname and proceeds with
|
||||
telemetry. All of this runs inside the ONE transaction the generic collector
|
||||
dispatcher owns (`shopdb/core/api/collector.py`): a `ValueError` still rolls
|
||||
the whole thing back, so a half-adopted PC can never be committed.
|
||||
- Steady-state reports send `adopt=false` (or omit it once a site chooses strict
|
||||
mode); only the installer's first run sends `adopt=true`. After adoption the
|
||||
strict "must already resolve" invariant holds for every subsequent report.
|
||||
|
||||
First-contact payload (the PC-identifying subset the shim forwards to the
|
||||
`computers` collector - every field except `hostname` is optional but recommended
|
||||
so the adopted PC is not a bare shell):
|
||||
|
||||
- `hostname` (required), `loggedinuser`, `serialnumber`, `pctype`,
|
||||
`machinenumber` (only when the PC is bound to a machine; `''`/`'9999'` are
|
||||
treated as none by the computers collector).
|
||||
|
||||
Net effect for the agent:
|
||||
|
||||
- ONE POST to `/api/collector/endpoints` with `adopt=true` on first run creates
|
||||
the Asset + PC (via the shim) and writes the first `EndpointState` in the same
|
||||
commit, so the endpoint appears in the UI immediately (no second call, no
|
||||
separate enrollment endpoint).
|
||||
- OPEN: whether to keep `adopt` per-report or promote it to a site setting
|
||||
(strict vs. self-adopting fleet). Default proposed: adoption allowed, agent
|
||||
sends `adopt=true` only on first run.
|
||||
|
||||
## 2. Report shape (current server contract)
|
||||
|
||||
Top-level payload fields the server currently reads (see
|
||||
`plugins/endpoints/plugin.py` `get_collector_schema`):
|
||||
|
||||
- `hostname` (required), `loggedinuser`, `agentversion`
|
||||
- Metrics: `lastboottime` (ISO-8601), `uptimeseconds`, `idleseconds`,
|
||||
`cpupercent`, `memorypercent`
|
||||
- Inventory arrays (stored as latest-snapshot JSON, not history):
|
||||
`services`, `ports`, `interfaces`, `scheduledtasks`
|
||||
- `events`: array of curated Windows Event Log records (see section 4)
|
||||
|
||||
Decision: inventory and metrics are a SNAPSHOT overwrite (one `EndpointState`
|
||||
row per asset), not time-series. The agent sends the full current list each
|
||||
cycle; the server does not diff or version them. If per-sample history is later
|
||||
required, it goes in a new append-only table and the contract stays additive.
|
||||
|
||||
## 3. Reporting rhythm
|
||||
|
||||
- Default report interval: 15 minutes (`report_interval_minutes` in the plugin
|
||||
manifest settings; a site setting can override). Agent design should make the
|
||||
interval configurable and jittered to avoid a fleet-wide thundering herd.
|
||||
- There is NO separate heartbeat. `lastreportedat` is stamped on every report;
|
||||
"quiet" endpoints are those past the quiet-report window (default 24h, site
|
||||
setting `endpoints_quietreporthours`). Agent does not need a distinct ping.
|
||||
|
||||
## 4. Curated Windows Event catalog (LOCKED to the server list)
|
||||
|
||||
The server stores ONLY these IDs; anything else in `events` is ignored (not an
|
||||
error). Source of truth: `CURATED_EVENT_IDS` in `plugins/endpoints/plugin.py`.
|
||||
|
||||
| Event ID | Channel | Meaning |
|
||||
|----------|--------------|--------------------------------------------|
|
||||
| 4624 | Security | Successful logon |
|
||||
| 4625 | Security | Failed logon |
|
||||
| 4634 | Security | Logoff |
|
||||
| 4672 | Security | Special privileges assigned to new logon |
|
||||
| 4697 | Security | Service installed |
|
||||
| 4698 | Security | Scheduled task created |
|
||||
| 41 | System | Kernel-Power, unexpected shutdown |
|
||||
| 4618 | Security | Monitored security event pattern |
|
||||
| 55 | System | NTFS file system corruption |
|
||||
| 7011 | System | Service Control Manager timeout |
|
||||
| 10016 | System | DCOM permission error |
|
||||
| 1001 | Application | Application error / bugcheck report |
|
||||
|
||||
- Per event, the agent sends: `eventid` (required), `eventchannel`,
|
||||
`eventsource`, `eventtime` (ISO-8601, event time not report time),
|
||||
`eventmessage`, and `fields` (an object of extracted structured values, e.g.
|
||||
target account on a 4625). `fields` is stored as `payloadjson`.
|
||||
- OPEN QUESTION for the agent build: which IDs need structured `fields` parsing
|
||||
vs. raw message pass-through. Start with pass-through message; add structured
|
||||
parsing per ID as alerting rules demand it. The teammate flagged event parsing
|
||||
as the hard part; keep the catalog fixed and small until baselined.
|
||||
- Scheduled-task history being disabled on some hosts means 4698 and task
|
||||
"last run" may be sparse; the agent should read current task state directly,
|
||||
not rely on event history alone.
|
||||
|
||||
## 5. Monitored services/processes
|
||||
|
||||
Known targets to watch (non-exhaustive, confirm during agent build): UDC, eDnc,
|
||||
Intune-related services. Server stores whatever list the agent sends in
|
||||
`servicesjson` without a fixed schema yet. Decision deferred: once the watched
|
||||
set is final, consider promoting it to a server-known enum for alerting.
|
||||
|
||||
## 6. ServiceNow ticketing surface (future, creds confirmed available)
|
||||
|
||||
Not built server-side yet. Recorded here so the agent and a future server
|
||||
endpoint agree:
|
||||
|
||||
- Interactive C# piece (system-tray/desktop launcher) owns the 5-click-max flow:
|
||||
network precheck -> issue dropdown -> optional screenshot -> submit.
|
||||
- Fixed dropdown reasons (from the source conversation): Can't logon to CSF;
|
||||
Data collections down; Can't download program; Machine communication failing;
|
||||
Network drives down; Other (free text).
|
||||
- Screenshot saved to local `Pictures\Screenshots` before submission.
|
||||
- OPEN: whether ticket submission goes direct to the ServiceNow API from the
|
||||
agent, or is proxied through a ShopDB server endpoint (preferred: proxy, so
|
||||
ServiceNow creds live server-side and the agent carries only its collector
|
||||
key). Decide before wiring the integration.
|
||||
|
||||
## 7. Runtime split (from the planning conversation)
|
||||
|
||||
- Interactive bits (tray launcher, ticketing, screenshot): C# / .NET, likely a
|
||||
Windows service + tray app.
|
||||
- Collections MAY be locally hosted PowerShell scripts the service invokes,
|
||||
where that is simpler than reimplementing WMI/CIM queries in C#.
|
||||
- Code signing and Intune deployment are agent-side concerns, out of scope for
|
||||
the server plugin but must be planned before fleet rollout.
|
||||
|
||||
### 7a. Tray deployment and on-demand "Send Status" (built)
|
||||
|
||||
The tray icon is a PER-USER process, not a service. A Windows service runs in
|
||||
session 0 and cannot draw a tray icon in the operator's session, so the tray
|
||||
ships as a second executable (`shopdb-endpoint-tray.exe`) that the installer
|
||||
copies next to `shopdbagent.exe`, registers for logon autostart via the HKCU
|
||||
Run key (`ShopDbEndpointTray`), and launches for the current user. Because the
|
||||
Run key is per-user, the installer must be run from the operator's own session;
|
||||
`-NoTray` yields a headless, service-only install.
|
||||
|
||||
DECISION - "Send Status now" reuses `--once`, it does not reimplement collection.
|
||||
The tray's Send Status action shells out to `shopdbagent.exe --once`, the same
|
||||
production collect-and-report path the service uses on its cadence. Consequences:
|
||||
the tray holds NO collector key (the agent exe reads it from registry/appsettings
|
||||
itself), there is one collection code path to maintain, and an operator can force
|
||||
the ShopDB monitor page to refresh immediately instead of waiting for the next
|
||||
cadence tick. The tray surfaces the agent's exit code as a balloon tip (0 =
|
||||
sent, non-zero = check the rolling agent log). Note: `--once` honors
|
||||
`adoptonfirstrun`, so an on-demand send can carry `adopt=true`; the server dedups
|
||||
on hostname, so this is safe.
|
||||
|
||||
The ServiceNow ticket action stays a scaffold (section 6). A future desktop
|
||||
shortcut to the same tray exe is the intended explicitly-launchable entry point
|
||||
for that workflow; nothing about the Send Status wiring blocks it.
|
||||
|
||||
## 8. Web UI display (server-side, built)
|
||||
|
||||
The collected data surfaces through ShopDB's existing declaration-driven UI
|
||||
contract (ADR-010). The plugin declares WHAT to show; generic core components
|
||||
render it. No plugin-specific Vue components were written. Audience is DT
|
||||
support / IT, NOT the shop-floor operator (the operator's surface is the
|
||||
agent-side ticket tool).
|
||||
|
||||
Surfaces implemented:
|
||||
|
||||
- Per-PC asset panels (computer assets only), on the PC detail page:
|
||||
- Endpoint status (keyvalue): logged-in user, uptime, idle, last boot,
|
||||
CPU/memory %, agent version, last reported. Feeds
|
||||
`GET /api/endpoints/asset/{assetid}/status`.
|
||||
- Services and processes (list + badge): monitored services with a green/red
|
||||
run-state badge. Feeds `.../services`.
|
||||
- Recent events (list + severity badge): last 10 curated events. Feeds
|
||||
`.../recentevents`.
|
||||
- Dashboard exception cards (hide when empty):
|
||||
- Endpoints gone quiet (warning): `.../dashboard/quiet`.
|
||||
- Endpoints with crashes or shutdowns (critical): `.../dashboard/stability`,
|
||||
bounded to a 48h window and the critical event catalog.
|
||||
|
||||
DECISION - freshness and cost. There is NO live streaming and NO polling. The
|
||||
freshness ceiling is the agent report cadence (~15 min), so a websocket/poll
|
||||
buys nothing but load. Data is fetched on page/dashboard load only, matching the
|
||||
existing computers-quiet card. Asset panels are single-asset indexed reads
|
||||
(negligible). Dashboard cards run once per dashboard load, cap at 50 rows, and
|
||||
the stability card is time-bounded so it does not grow with total history. If
|
||||
fleet-wide aggregation ever gets heavy, memoize dashboard aggregates with the
|
||||
core `cache` helper for ~60s - documented lever, not built yet.
|
||||
|
||||
Agent implication: the agent does not drive any UI refresh. Its only job is to
|
||||
report on its rhythm; the UI reflects whatever the last report wrote. The
|
||||
`services` payload should send a per-service run STATE (e.g. running/stopped) so
|
||||
the badge can color it; a missing/unknown state renders as a gray Unknown badge.
|
||||
|
||||
## 9. Shop-floor collection knowledge base (reference, NOT a schema change)
|
||||
|
||||
This section is a body of reference, not a change request. Nothing here alters
|
||||
the shape of ShopDB as it stands. It records WHAT a shop-floor endpoint really
|
||||
is, WHICH signals identify it, and WHY each signal matters to the enterprise, so
|
||||
that whoever grows the collector understands the stakes behind each field before
|
||||
touching the wire contract. The source material is the site's own extant
|
||||
tooling: `scripts/site_imports/assetcollector/Collect-ShopDB.ps1` (the mature
|
||||
CSV collector), the migration/assessment flow under `scripts/.temp`
|
||||
(`Assess-Orchestrator.ps1`, `Setup_ShopPC.ps1`, `Setup_InspectionPC.ps1`,
|
||||
`eDncRegToUDC.ps1`, `machinetypeselector.bat`), and the eDNC/UDC/MTConnect config
|
||||
formats those scripts read. Treat it as the "why" that the terse contract in
|
||||
sections 1 through 8 cannot carry.
|
||||
|
||||
### 9.0 Why this matters beyond one plugin
|
||||
|
||||
This collector is a wedge into an enterprise-wide body of knowledge. A shop-floor
|
||||
PC is never just a PC: it is the human-facing half of a machine tool, the thing
|
||||
that feeds NC programs to a controller and streams data back off it. Getting its
|
||||
identity, hardware, network, and application picture right lets ShopDB answer
|
||||
questions that today require a person to walk the floor: which controller does
|
||||
this box talk to, is its data path healthy, is the hardware under warranty, what
|
||||
is it actually running. Every signal below earns its place by answering one of
|
||||
those questions.
|
||||
|
||||
### 9.1 The endpoint taxonomy (how the floor classifies its own PCs)
|
||||
|
||||
`machinetypeselector.bat` plus the two `Setup_*` scripts show the site already
|
||||
thinks in three roles. The collector should be able to DERIVE the role from
|
||||
on-box evidence rather than ask an operator:
|
||||
|
||||
- Shop PC: runs UDC (Universal Data Collector) and/or eDNC, wired to one CNC
|
||||
machine (FANUC / Siemens / Okuma controller). This is the ~2/3 majority.
|
||||
- Inspection PC: metrology stack. Marked by `C:\GE Aerospace\Genspect\genspect.true`
|
||||
(a flag file `Setup_InspectionPC.ps1` writes) and/or `C:\GE Aerospace\Gagecal`
|
||||
(GageCal 6.4).
|
||||
- Non-shop-floor PC: office box, no machine link, minimal shop apps.
|
||||
|
||||
Role is derivable from application/marker presence (section 9.4), which is a more
|
||||
honest signal than the raw WMI `Model` string the current `pctype` probe sends.
|
||||
|
||||
### 9.2 Hardware facts, and WHY
|
||||
|
||||
`Collect-ShopDB.ps1 :: Get-PcFacts` reads `Win32_ComputerSystem`,
|
||||
`Win32_OperatingSystem`, and `Win32_BIOS` for: vendor/manufacturer, model, OS
|
||||
caption, serial (BIOS serial first, ComputerSystem serial as fallback), last
|
||||
boot, logged-in user.
|
||||
|
||||
Why hardware is not cosmetic:
|
||||
|
||||
- Dell Warranty API linkage. Vendor + model family + service tag (BIOS serial)
|
||||
is exactly the tuple the Dell Warranty API keys on. Capturing it lets ShopDB
|
||||
resolve warranty status and hardware line remotely, so a support tech can
|
||||
diagnose a hardware-level fault (failing disk, EOL platform, out-of-warranty
|
||||
chassis) without physically finding the machine. The serial is the join key;
|
||||
the model family is the diagnostic context.
|
||||
- Remote hardware diagnosis generally. Knowing the exact hardware line turns
|
||||
"some PC is flaky" into "this is a known-bad model/BIOS combination," which is
|
||||
an enterprise-scale pattern once the fleet reports uniformly.
|
||||
|
||||
Collection note: prefer BIOS `SerialNumber` (the Dell service tag) and capture
|
||||
manufacturer + model as distinct fields, since the warranty join needs the raw
|
||||
service tag, not a normalized `pctype`.
|
||||
|
||||
### 9.3 Network interfaces, and WHY (the second-NIC reality)
|
||||
|
||||
`Get-PcFacts` and `Assess-Orchestrator.ps1 :: Get-EthernetNetworkSnapshot`
|
||||
enumerate hardware adapters only and capture alias, IPv4, MAC, gateway, and
|
||||
subnet (the orchestrator even reconstructs the mask from prefix length).
|
||||
|
||||
Why the interface list is first-class, not trivia:
|
||||
|
||||
- The second interface IS the machine link. Upwards of two-thirds of shop-floor
|
||||
PCs have a SECOND network interface with a STATIC, manually mapped IP address
|
||||
whose sole purpose is to talk to the related machine's onboard IPS
|
||||
(industrial PC / controller network). That private NIC is physical evidence of
|
||||
the PC-to-machine relationship and often the only place the machine-side
|
||||
address is recorded. Losing it loses the wiring diagram of the floor.
|
||||
- MAC as stable identity. MAC is the most stable hardware identifier across
|
||||
re-image and rename, which is why every extant script grabs it. It is the
|
||||
durable anchor when hostname/asset number churn during migration.
|
||||
|
||||
Collection note: send ALL hardware interfaces (alias, IPv4, MAC, and where
|
||||
available gateway/subnet), not just the primary. The primary/gateway NIC is the
|
||||
corporate LAN; the gatewayless static NIC is very likely the machine IPS link and
|
||||
is the interesting one.
|
||||
|
||||
### 9.4 Installed applications, and WHY (the extrapolation engine)
|
||||
|
||||
`Get-PcFacts` reads the registry Uninstall keys (name, version, publisher,
|
||||
skipping `SystemComponent`). On these boxes the installed-app set is small and
|
||||
highly diagnostic: they primarily run a handful of known apps.
|
||||
|
||||
Why the app inventory is the richest signal we have:
|
||||
|
||||
- Apps imply role (section 9.1): UDC/eDNC => Shop PC; Genspect/GageCal =>
|
||||
Inspection PC; neither => office.
|
||||
- Apps imply the MACHINE binding. This is the crux. If UDC or eDNC (or MTConnect)
|
||||
is installed, its config file carries the four-digit number ShopDB calls the
|
||||
Machine Number, and from it we can extrapolate the ShopDB `machines` entry the
|
||||
PC should be related to. The same value appears under different names across
|
||||
the app generations:
|
||||
- eDNC `.reg` / live registry: `MachineNo` (under
|
||||
`HKLM\SOFTWARE\[WOW6432Node\]GE Aircraft Engines\DNC\General`).
|
||||
- UDC `udc_settings_*.json`: `GeneralSettings.MachineNumber`.
|
||||
- MTConnect: workstation / workstation ID / MachineNo, carried in the
|
||||
adapter/agent config under the fixed directory tree documented in section
|
||||
9.9.
|
||||
Treat "MachineNo", "workstation", "workstation ID", and "MachineNumber" as the
|
||||
same concept: the ShopDB Machine Number. Version and publisher are worth
|
||||
keeping too (they date the app generation, which tells you which config format
|
||||
to parse).
|
||||
|
||||
Collection note: the app inventory is not just an asset list; it is the pointer
|
||||
that tells the collector WHICH config file to open next to find the machine
|
||||
binding.
|
||||
|
||||
### 9.5 The machine binding (DNC/UDC/MTConnect config), and WHY
|
||||
|
||||
`Collect-ShopDB.ps1 :: Resolve-DncSource` resolves the machine config with a
|
||||
SOURCE-PRIORITY chain, then three parsers normalize the formats into one shape:
|
||||
|
||||
- Priority: explicit JSON path -> explicit `.reg` path -> live registry
|
||||
(`HKLM\...\GE Aircraft Engines\DNC`) -> local `.reg` export -> local
|
||||
`udc_settings_*.json`. This ordering makes one probe work across both the
|
||||
eDNC-era and UDC-era boxes.
|
||||
- Parsers: `ConvertFrom-RegExport` (parses exported `.reg`, including
|
||||
`dword:` decoding), `ConvertFrom-LiveRegistry`, `ConvertFrom-UdcJson`.
|
||||
- Extracted: machine number, site, control type, DNC type, and connection detail
|
||||
(TCP address/port for ethernet; COM port/baud for serial).
|
||||
- Controller vendor is DERIVED, not stored raw: `Resolve-ControllerVendor` maps
|
||||
`NcIF = EFOCAS -> FANUC`, `Cnc = SIEMENS -> Siemens`, `Cnc = OKUMA -> Okuma`.
|
||||
`eDncRegToUDC.ps1` confirms the same mapping and the eFocas/PPDCS key layout.
|
||||
|
||||
Why it matters: this binding is what upgrades a bare endpoint into "the PC that
|
||||
controls machine 1234 over eFOCAS at 10.x.x.x." That relationship is the point of
|
||||
the whole exercise; the hardware and app facts are supporting evidence for it.
|
||||
|
||||
### 9.6 Printers, and WHY
|
||||
|
||||
`Assess-Orchestrator.ps1 :: Get-PrinterSnapshot` / `Get-PrinterPortSnapshot`
|
||||
capture name, driver, port, host address, shared/published, and the default
|
||||
flag, filtering out virtual outputs (Print to PDF, XPS, Fax, OneNote). Shop and
|
||||
inspection PCs are tied to specific physical/label printers, so the printer set
|
||||
is part of a workstation's operational identity and a common migration break
|
||||
point (a replaced PC that lost its label printer is a floor stoppage).
|
||||
|
||||
### 9.7 Patterns from the extant tooling worth carrying forward
|
||||
|
||||
Two engineering habits in these scripts are worth honoring in the collector,
|
||||
because they encode hard-won migration lessons:
|
||||
|
||||
- Field-level idempotent merge (`Merge-CsvStore`): an empty incoming cell NEVER
|
||||
overwrites a previously captured value; later partial runs fill gaps without
|
||||
wiping earlier data. The server's endpoints ingest already mirrors this instinct
|
||||
(`if 'field' in payload`), and it should stay that way: a partial report means
|
||||
"leave the rest alone," not "clear it."
|
||||
- Normalized intermediate shape + sidecars: heterogeneous sources (registry,
|
||||
`.reg`, JSON, WMI) all converge on one normalized record before import, with
|
||||
phase-2 data (interfaces, installed apps, PC-to-machine link) written as JSON
|
||||
sidecars. The same decoupling lets a future non-agent source (Intune/Azure
|
||||
`Get-MgDevice`, a MES/CNC database) feed the same model without a new pipeline.
|
||||
|
||||
### 9.9 MTConnect footprint, and WHY (three parallel stacks per box)
|
||||
|
||||
Source: `scripts/.temp/Install_MTConnect_ExisDir.ps1`,
|
||||
`Install_MTConnect_ExisDir_BatConvert.ps1`, its usage guide
|
||||
`Guide-Install_MTConnect_ExisDir_BatConvert.md`, and
|
||||
`Firewall_Rules_MTConnect_All.bat`. MTConnect is the standardized data-off-the-
|
||||
machine layer, and the install scripts reveal its exact on-disk shape, which is
|
||||
what a collector needs to detect it reliably.
|
||||
|
||||
The critical structural fact: an endpoint can host up to THREE parallel MTConnect
|
||||
stacks, one per data path, each an Adapter (talks to the machine) plus an Agent
|
||||
(serves the MTConnect HTTP feed):
|
||||
|
||||
- Machine-direct: `C:\MTConnect\Adapter` + `C:\MTConnect\Agent` (Agent exe
|
||||
`C:\MTConnect\Agent\Agent.exe`).
|
||||
- eDNC-fed: `C:\MTConnect_eDNC\Adapter` + `C:\MTConnect_eDNC\Agent`
|
||||
(`...\Agent\agent.exe`). The eDNC adapter is `DNCAdapter.exe` driven by
|
||||
`DNCAdapter.ini`, tying this path back to the eDNC config world in section 9.5.
|
||||
- UDC-fed: `C:\ProgramData\udc\mtconnect_udc\agent` (`...\agent\agent.exe`,
|
||||
launched via `run.bat`). This binds MTConnect to the UDC install and therefore
|
||||
to the same `udc_settings_*.json` that carries `GeneralSettings.MachineNumber`.
|
||||
|
||||
Why the collector should care about these paths:
|
||||
|
||||
- Presence is role/path evidence. Which of the three trees exist tells you HOW the
|
||||
machine's data is being lifted (direct vs eDNC vs UDC), which is a real
|
||||
operational distinction, not a cosmetic one.
|
||||
- The MTConnect Agent is a network service. The firewall bat and the
|
||||
`Ensure-FirewallProgramRule` calls open inbound rules named
|
||||
`Allow_MTConnect_Machine`, `Allow_MTConnect_UDC`, `Allow_MTConnect_eDNC` for
|
||||
each `Agent.exe`. That means each agent listens on an HTTP port, so its live
|
||||
MTConnect feed (which publishes a device UUID / station identity) is itself a
|
||||
future data source the collector could probe, not just a process to count.
|
||||
- Machine Number lives here too. Because the eDNC path is `DNCAdapter.ini` and the
|
||||
UDC path is the UDC settings JSON, the MTConnect footprint is a second route to
|
||||
the same four-digit Machine Number described in 9.4/9.5, cross-checkable against
|
||||
the eDNC/UDC values.
|
||||
|
||||
Collection note: detect MTConnect by the three directory trees, capture which
|
||||
Agent exes exist and whether their service/process is running (section 5 already
|
||||
covers MTConnect as a monitored service), and record the data path
|
||||
(direct/eDNC/UDC).
|
||||
(`Install_MTConnect_ExisDir_BatConvert.ps1`) is a deployment concern, not a
|
||||
collection concern, but it explains why these `install.bat`/`start.bat` files
|
||||
were rewritten to self-elevate without a `pause`: so they can run unattended
|
||||
under DSC on locked-down machines. Do not treat a self-elevating batch as
|
||||
tampering.
|
||||
|
||||
### 9.8 Boundary (explicit)
|
||||
|
||||
None of section 9 is a ShopDB schema proposal. Where a signal above has no home
|
||||
in the current model, that is fine: it stays reference until a separate, deliberate
|
||||
decision (its own ADR / contract bump) decides to store it. The value here is
|
||||
comprehension: anyone extending the collector should know that hardware feeds
|
||||
warranty, the second NIC is the machine link, and the installed apps are the
|
||||
thread that leads to the Machine Number. Build the agent with that understanding;
|
||||
do not let this document, by itself, move the schema.
|
||||
5
plugins/endpoints/__init__.py
Normal file
5
plugins/endpoints/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Endpoints plugin package."""
|
||||
|
||||
from .plugin import EndpointsPlugin
|
||||
|
||||
__all__ = ['EndpointsPlugin']
|
||||
5
plugins/endpoints/api/__init__.py
Normal file
5
plugins/endpoints/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Endpoints plugin API."""
|
||||
|
||||
from .routes import endpoints_bp
|
||||
|
||||
__all__ = ['endpoints_bp']
|
||||
724
plugins/endpoints/api/routes.py
Normal file
724
plugins/endpoints/api/routes.py
Normal file
@@ -0,0 +1,724 @@
|
||||
"""Endpoints plugin API endpoints (read + dashboard).
|
||||
|
||||
Ingest is NOT here: agent reports arrive on the generic collector route
|
||||
POST /api/collector/endpoints (ADR-006), authorized by a collector-scoped
|
||||
service token, and are handled by EndpointsPlugin.apply_collector_payload.
|
||||
These routes are the human-facing read surface, gated by user JWT + RBAC.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from flask import Blueprint, request, current_app
|
||||
from flask_jwt_extended import current_user
|
||||
|
||||
from shopdb.api import (
|
||||
db, Asset, Setting, success_response, error_response, paginated_response,
|
||||
ErrorCodes, get_pagination_params, paginate_query, require_permission,
|
||||
require_role, mint_service_token, list_service_tokens, revoke_service_tokens,
|
||||
AuditLog, collector_authorized,
|
||||
)
|
||||
|
||||
from ..models import EndpointState, EndpointEvent, EndpointCommand
|
||||
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for
|
||||
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
|
||||
|
||||
endpoints_bp = Blueprint('endpoints', __name__)
|
||||
|
||||
|
||||
def _naive_utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _format_uptime(seconds):
|
||||
"""Whole-seconds uptime -> 'Nd Nh Nm', or None."""
|
||||
if seconds is None:
|
||||
return None
|
||||
try:
|
||||
seconds = int(seconds)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
days, rem = divmod(seconds, 86400)
|
||||
hours, rem = divmod(rem, 3600)
|
||||
minutes = rem // 60
|
||||
parts = []
|
||||
if days:
|
||||
parts.append('{}d'.format(days))
|
||||
if hours:
|
||||
parts.append('{}h'.format(hours))
|
||||
parts.append('{}m'.format(minutes))
|
||||
return ' '.join(parts)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Endpoint state
|
||||
# =============================================================================
|
||||
|
||||
@endpoints_bp.route('/', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def list_endpoints():
|
||||
"""List reported endpoint states.
|
||||
|
||||
Joins through Computer so each row carries computerid and can deep-link
|
||||
into the existing PC detail page (/pcs/{computerid}). Endpoint state only
|
||||
exists for hostnames resolved to a Computer, so the join is not lossy.
|
||||
"""
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = (db.session.query(EndpointState, Computer.computerid)
|
||||
.join(Asset, Asset.assetid == EndpointState.assetid)
|
||||
.join(Computer, Computer.assetid == EndpointState.assetid)
|
||||
.filter(Asset.isactive.is_(True)))
|
||||
|
||||
if search := request.args.get('search'):
|
||||
query = query.filter(EndpointState.hostname.ilike(f'%{search}%'))
|
||||
|
||||
query = query.order_by(EndpointState.lastreportedat.desc())
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = []
|
||||
for state, computerid in items:
|
||||
row = state.to_dict()
|
||||
row['computerid'] = computerid
|
||||
row['uptimedisplay'] = _format_uptime(state.uptimeseconds)
|
||||
data.append(row)
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@endpoints_bp.route('/<int:asset_id>', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def get_endpoint(asset_id: int):
|
||||
"""Get the current state for one endpoint by asset id."""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
|
||||
if not state:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'No endpoint state for asset {asset_id}',
|
||||
http_code=404,
|
||||
)
|
||||
|
||||
return success_response(state.to_dict())
|
||||
|
||||
|
||||
@endpoints_bp.route('/<int:asset_id>/events', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def list_endpoint_events(asset_id: int):
|
||||
"""List curated Windows events for one endpoint, newest first."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = EndpointEvent.query.filter_by(assetid=asset_id)
|
||||
|
||||
if eventid := request.args.get('eventid'):
|
||||
try:
|
||||
query = query.filter(EndpointEvent.eventid == int(eventid))
|
||||
except (TypeError, ValueError):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'eventid must be an integer')
|
||||
|
||||
query = query.order_by(EndpointEvent.eventtime.desc())
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = [e.to_dict() for e in items]
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Asset panels (ADR-010) - shaped for the generic PluginAssetPanels renderer
|
||||
# =============================================================================
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/status', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_status(asset_id: int):
|
||||
"""Keyvalue panel: the endpoint's current status snapshot.
|
||||
|
||||
Single indexed lookup on endpointstates.assetid. Returns the {fields:[...]}
|
||||
shape the keyvalue renderer expects; an absent state yields no fields so the
|
||||
panel hides itself.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
if not state:
|
||||
return success_response({'fields': []})
|
||||
|
||||
def field(label, value, mono=False):
|
||||
return {'label': label, 'value': value, 'mono': mono}
|
||||
|
||||
fields = [
|
||||
field('Logged in user', state.loggedinuser),
|
||||
field('Uptime', _format_uptime(state.uptimeseconds)),
|
||||
field('Idle', _format_uptime(state.idleseconds)),
|
||||
field('Last boot',
|
||||
state.lastboottime.isoformat() + 'Z' if state.lastboottime else None),
|
||||
field('CPU %', state.cpupercent),
|
||||
field('Memory %', state.memorypercent),
|
||||
field('Agent', state.agentversion, mono=True),
|
||||
field('Last reported',
|
||||
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None),
|
||||
]
|
||||
fields = [f for f in fields if f['value'] not in (None, '')]
|
||||
return success_response({'fields': fields})
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/telemetrymeta', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_telemetry_meta(asset_id: int):
|
||||
"""Lightweight freshness probe for the panels.
|
||||
|
||||
Returns just the endpoint's lastreportedat so the WebUI can tell whether a
|
||||
genuinely new telemetry snapshot has landed after it forces a collect. The
|
||||
UI captures this before enqueuing collect_now (or a row Start/Stop), then
|
||||
polls until the stamp advances - a command reaching 'succeeded' only means
|
||||
the agent ran it, not that it has yet posted the fresh services snapshot, so
|
||||
gating the refresh on this timestamp is what avoids re-rendering stale rows.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
stamp = (state.lastreportedat.isoformat() + 'Z'
|
||||
if state and state.lastreportedat else None)
|
||||
return success_response({'lastreportedat': stamp})
|
||||
|
||||
|
||||
@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.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
if not state or not state.servicesjson:
|
||||
return success_response([])
|
||||
|
||||
try:
|
||||
services = json.loads(state.servicesjson)
|
||||
except (TypeError, ValueError):
|
||||
return success_response([])
|
||||
|
||||
rows = []
|
||||
for entry in (services or []):
|
||||
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()
|
||||
app = entry.get('app')
|
||||
kind = entry.get('kind')
|
||||
else:
|
||||
name, raw, app, kind = str(entry), '', None, None
|
||||
running = raw in ('running', 'started', 'up', 'ok', 'active')
|
||||
row = {
|
||||
'name': name,
|
||||
'kind': (kind or '').lower() or None,
|
||||
'statelabel': 'Running' if running else (raw.title() or 'Unknown'),
|
||||
'statecolor': 'green' if running else ('red' if raw else 'gray'),
|
||||
}
|
||||
targets = row_actions_for(app, kind, name)
|
||||
if targets:
|
||||
row['action'] = {
|
||||
'running': running,
|
||||
'start': targets['start'],
|
||||
'stop': targets['stop'],
|
||||
'status': targets['status'],
|
||||
'target': targets['target'],
|
||||
'targetkind': targets['targetkind'],
|
||||
}
|
||||
rows.append(row)
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/recentevents', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_recent_events(asset_id: int):
|
||||
"""List panel: the endpoint's most recent curated events, newest first.
|
||||
|
||||
Indexed range scan on (assetid, eventtime), capped small - this is the
|
||||
at-a-glance history card, not the full report (that is the events route
|
||||
with pagination).
|
||||
"""
|
||||
events = (EndpointEvent.query
|
||||
.filter_by(assetid=asset_id)
|
||||
.order_by(EndpointEvent.eventtime.desc())
|
||||
.limit(10)
|
||||
.all())
|
||||
|
||||
rows = [{
|
||||
'title': event_title(e.eventid),
|
||||
'severity': event_severity(e.eventid),
|
||||
'eventtime': e.eventtime.isoformat() + 'Z' if e.eventtime else None,
|
||||
} for e in events]
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/interfaces', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_interfaces(asset_id: int):
|
||||
"""Table panel: every hardware NIC the agent last reported.
|
||||
|
||||
Reshapes the latest interfacesjson snapshot into one row per adapter with
|
||||
the {alias, ipv4, mac, gateway, subnet, dhcp} shape the collector emits. A
|
||||
gatewayless adapter is flagged as the likely machine-IPS link (see
|
||||
DESIGN-DECISIONS-agent.md 9.3): those NICs speak to the machine, not the
|
||||
corporate LAN. Tolerant of a missing or unexpected snapshot: an unreadable
|
||||
payload yields no rows so the panel hides itself.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
if not state or not state.interfacesjson:
|
||||
return success_response([])
|
||||
|
||||
try:
|
||||
interfaces = json.loads(state.interfacesjson)
|
||||
except (TypeError, ValueError):
|
||||
return success_response([])
|
||||
|
||||
rows = []
|
||||
for entry in (interfaces or []):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
gateway = (entry.get('gateway') or '').strip()
|
||||
ipv4 = (entry.get('ipv4') or '').strip()
|
||||
rows.append({
|
||||
'alias': entry.get('alias') or 'Unknown',
|
||||
'ipv4': ipv4 or '-',
|
||||
'mac': (entry.get('mac') or '').strip() or '-',
|
||||
'gateway': gateway or '-',
|
||||
'subnet': (entry.get('subnet') or '').strip() or '-',
|
||||
'dhcp': 'DHCP' if entry.get('dhcp') else 'Static',
|
||||
# A gatewayless NIC with an address is the machine link; the gateway
|
||||
# NIC is the corporate LAN.
|
||||
'role': 'Machine link' if (ipv4 and not gateway) else 'LAN',
|
||||
})
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dashboard
|
||||
# =============================================================================
|
||||
|
||||
@endpoints_bp.route('/dashboard/summary', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def dashboard_summary():
|
||||
"""High-level fleet counts for the endpoints dashboard."""
|
||||
total = db.session.query(EndpointState).join(
|
||||
Asset, Asset.assetid == EndpointState.assetid
|
||||
).filter(Asset.isactive.is_(True)).count()
|
||||
|
||||
hours = _quiet_hours()
|
||||
cutoff = _naive_utcnow() - timedelta(hours=hours)
|
||||
|
||||
reporting = db.session.query(EndpointState).join(
|
||||
Asset, Asset.assetid == EndpointState.assetid
|
||||
).filter(
|
||||
Asset.isactive.is_(True),
|
||||
EndpointState.lastreportedat.isnot(None),
|
||||
EndpointState.lastreportedat >= cutoff,
|
||||
).count()
|
||||
|
||||
return success_response({
|
||||
'total': total,
|
||||
'reporting': reporting,
|
||||
'quiet': total - reporting,
|
||||
'quiethours': hours,
|
||||
})
|
||||
|
||||
|
||||
@endpoints_bp.route('/dashboard/quiet', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def dashboard_quiet():
|
||||
"""Endpoints that reported before, then went quiet past the window.
|
||||
|
||||
Never-reported endpoints are excluded: those are records without an agent,
|
||||
not agents that stopped. Silence is the only signal - there is no heartbeat
|
||||
separate from the report.
|
||||
"""
|
||||
hours = _quiet_hours()
|
||||
cutoff = _naive_utcnow() - timedelta(hours=hours)
|
||||
now = _naive_utcnow()
|
||||
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
rows = (db.session.query(EndpointState, Computer.computerid)
|
||||
.join(Asset, Asset.assetid == EndpointState.assetid)
|
||||
.join(Computer, Computer.assetid == EndpointState.assetid)
|
||||
.filter(Asset.isactive.is_(True),
|
||||
EndpointState.lastreportedat.isnot(None),
|
||||
EndpointState.lastreportedat < cutoff)
|
||||
.all())
|
||||
|
||||
out = [{
|
||||
'computerid': computerid,
|
||||
'assetid': s.assetid,
|
||||
'hostname': s.hostname,
|
||||
'lastreported': s.lastreportedat.isoformat() + 'Z',
|
||||
'quietdays': (now - s.lastreportedat).days,
|
||||
} for s, computerid in rows]
|
||||
|
||||
out.sort(key=lambda r: -r['quietdays'])
|
||||
return success_response(out[:50])
|
||||
|
||||
|
||||
@endpoints_bp.route('/dashboard/stability', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def dashboard_stability():
|
||||
"""Endpoints with recent crash/shutdown/corruption events.
|
||||
|
||||
Bounded to a fixed 48h window and the critical event catalog, capped at 50
|
||||
rows: cost is a range scan on eventtime filtered to a short id list, not a
|
||||
scan of all history. Joins through Computer so each row links to the PC
|
||||
detail page.
|
||||
"""
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
if not CRITICAL_EVENT_IDS:
|
||||
return success_response([])
|
||||
|
||||
cutoff = _naive_utcnow() - timedelta(hours=48)
|
||||
|
||||
rows = (db.session.query(
|
||||
Computer.computerid,
|
||||
EndpointState.hostname,
|
||||
db.func.count(EndpointEvent.endpointeventid),
|
||||
db.func.max(EndpointEvent.eventtime),
|
||||
)
|
||||
.select_from(EndpointEvent)
|
||||
.join(Asset, Asset.assetid == EndpointEvent.assetid)
|
||||
.join(Computer, Computer.assetid == EndpointEvent.assetid)
|
||||
.outerjoin(EndpointState,
|
||||
EndpointState.assetid == EndpointEvent.assetid)
|
||||
.filter(Asset.isactive.is_(True),
|
||||
EndpointEvent.eventid.in_(CRITICAL_EVENT_IDS),
|
||||
EndpointEvent.eventtime.isnot(None),
|
||||
EndpointEvent.eventtime >= cutoff)
|
||||
.group_by(Computer.computerid, EndpointState.hostname)
|
||||
.all())
|
||||
|
||||
out = [{
|
||||
'computerid': computerid,
|
||||
'hostname': hostname,
|
||||
'eventcount': count,
|
||||
'lastevent': lasttime.isoformat() + 'Z' if lasttime else None,
|
||||
} for computerid, hostname, count, lasttime in rows]
|
||||
|
||||
out.sort(key=lambda r: -r['eventcount'])
|
||||
return success_response(out[:50])
|
||||
|
||||
|
||||
def _quiet_hours() -> int:
|
||||
"""Quiet-report window in hours, from a site setting, default 24."""
|
||||
hours = 24
|
||||
setting = Setting.query.filter_by(key='endpoints_quietreporthours').first()
|
||||
if setting and (setting.value or '').strip():
|
||||
try:
|
||||
hours = int(setting.value)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return hours
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Endpoint agent key (UI-managed collector.ingest.endpoints kill switch)
|
||||
# =============================================================================
|
||||
|
||||
# The single permission the endpoint agent key authorizes. A managed token
|
||||
# scoped to ONLY this authorizes the endpoints collector and nothing else, so
|
||||
# rotating/revoking it stops endpoint-agent traffic without touching the
|
||||
# computers/geenforce collectors.
|
||||
AGENT_KEY_SCOPE = 'collector.ingest.endpoints'
|
||||
AGENT_KEY_NAME = 'Endpoint agent key'
|
||||
|
||||
|
||||
def _agentkey_status_payload():
|
||||
"""Current state of the endpoint agent key for the settings page.
|
||||
|
||||
Reports whether an active managed key exists (and its non-secret metadata)
|
||||
plus whether the bootstrap .env fallback is still accepted. Never exposes a
|
||||
secret; the full secret is only ever returned once, at regenerate time.
|
||||
"""
|
||||
tokens = list_service_tokens(AGENT_KEY_SCOPE)
|
||||
active = tokens[0] if tokens else None
|
||||
envfallback = Setting.get('plugin.endpoints.envkeyfallback', default=True)
|
||||
provisioned = Setting.get('plugin.endpoints.agentkeyprovisioned',
|
||||
default=False)
|
||||
return {
|
||||
'provisioned': bool(provisioned) or active is not None,
|
||||
'envkeyfallback': bool(envfallback),
|
||||
'key': active.to_dict() if active is not None else None,
|
||||
}
|
||||
|
||||
|
||||
@endpoints_bp.route('/agentkey', methods=['GET'])
|
||||
@require_role('admin')
|
||||
def get_agentkey():
|
||||
"""Report the endpoint agent key state (no secret)."""
|
||||
return success_response(_agentkey_status_payload())
|
||||
|
||||
|
||||
@endpoints_bp.route('/agentkey/regenerate', methods=['POST'])
|
||||
@require_role('admin')
|
||||
def regenerate_agentkey():
|
||||
"""Mint a fresh endpoint agent key, revoking any previous one.
|
||||
|
||||
Hard cutover: the old key is deactivated in the same call, and the .env
|
||||
fallback is switched off, so the new managed token becomes the sole
|
||||
credential and revoking it is a true kill switch. The full secret is
|
||||
returned ONCE here and never again.
|
||||
"""
|
||||
try:
|
||||
token, secret = mint_service_token(
|
||||
AGENT_KEY_SCOPE, AGENT_KEY_NAME, current_user.userid)
|
||||
except PermissionError as error:
|
||||
return error_response(ErrorCodes.FORBIDDEN, str(error), http_code=403)
|
||||
|
||||
Setting.set('plugin.endpoints.agentkeyprovisioned', True,
|
||||
valuetype='boolean', category='plugin.endpoints')
|
||||
Setting.set('plugin.endpoints.envkeyfallback', False,
|
||||
valuetype='boolean', category='plugin.endpoints')
|
||||
|
||||
result = _agentkey_status_payload()
|
||||
# The secret appears here and NOWHERE else, ever. Not stored, not logged.
|
||||
result['secret'] = secret
|
||||
result['warning'] = ('Save this key now. It will not be shown again. '
|
||||
'Configure it as the agent X-API-Key.')
|
||||
return success_response(result, message='Endpoint agent key generated',
|
||||
http_code=201)
|
||||
|
||||
|
||||
@endpoints_bp.route('/agentkey', methods=['DELETE'])
|
||||
@require_role('admin')
|
||||
def revoke_agentkey():
|
||||
"""Revoke the endpoint agent key: stop all managed-token endpoint traffic.
|
||||
|
||||
Leaves envkeyfallback off (the kill switch stays armed). To restore
|
||||
reporting an operator regenerates a new key.
|
||||
"""
|
||||
revoked = revoke_service_tokens(AGENT_KEY_SCOPE)
|
||||
Setting.set('plugin.endpoints.agentkeyprovisioned', False,
|
||||
valuetype='boolean', category='plugin.endpoints')
|
||||
result = _agentkey_status_payload()
|
||||
result['revoked'] = revoked
|
||||
return success_response(result, message='Endpoint agent key revoked')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Endpoint commands (WebUI -> agent, allowlisted actions only)
|
||||
# =============================================================================
|
||||
#
|
||||
# Operators enqueue a named, server-allowlisted action for one endpoint (JWT +
|
||||
# endpoints.command). The agent long-polls /commands/next with its collector
|
||||
# token, claims the single next pending command, runs it locally, and reports
|
||||
# the outcome to /commands/<id>/result. The wire never carries a command string
|
||||
# - only an allowlisted action name - so a compromised WebUI cannot ask an
|
||||
# endpoint to run something arbitrary.
|
||||
|
||||
# How long the agent claim poll is held open server-side before returning empty.
|
||||
# Kept below the agent HttpClient timeout and typical proxy idle timeouts so a
|
||||
# quiet fleet re-polls cleanly instead of tripping a read timeout.
|
||||
_CLAIM_HOLD_SECONDS = 25
|
||||
_CLAIM_POLL_INTERVAL = 1.0
|
||||
|
||||
|
||||
def _collector_auth_or_error():
|
||||
"""Authorize an agent-facing command route on the endpoints collector token.
|
||||
|
||||
Reuses the collector auth path so the command channel shares the endpoint
|
||||
agent key (kill switch) and introduces no second secret.
|
||||
"""
|
||||
return collector_authorized('endpoints')
|
||||
|
||||
|
||||
def _resolve_asset_for_hostname(hostname):
|
||||
"""Resolve a reported hostname to its asset id via the computers plugin."""
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
computer = (Computer.query
|
||||
.filter(db.func.lower(Computer.hostname) == hostname.lower())
|
||||
.first())
|
||||
return computer.assetid if computer is not None else None
|
||||
|
||||
|
||||
@endpoints_bp.route('/<int:asset_id>/commands', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def list_endpoint_commands(asset_id: int):
|
||||
"""Recent commands for one endpoint, newest first (operator view)."""
|
||||
rows = (EndpointCommand.query
|
||||
.filter_by(assetid=asset_id)
|
||||
.order_by(EndpointCommand.createdat.desc())
|
||||
.limit(20)
|
||||
.all())
|
||||
return success_response([row.to_dict() for row in rows])
|
||||
|
||||
|
||||
@endpoints_bp.route('/<int:asset_id>/commands', methods=['POST'])
|
||||
@require_permission('endpoints.command')
|
||||
def enqueue_endpoint_command(asset_id: int):
|
||||
"""Queue an allowlisted command for one endpoint.
|
||||
|
||||
Rejects unknown actions and refuses to stack a second pending/running
|
||||
command on the same endpoint, so the queue stays at-most-one-in-flight and
|
||||
the WebUI toggle cannot race itself.
|
||||
"""
|
||||
payload = request.get_json(silent=True) or {}
|
||||
action = (payload.get('action') or '').strip()
|
||||
if not is_allowed_action(action):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'Unknown or disallowed action: {action!r}')
|
||||
|
||||
# Destructive actions (for example system_restart) demand a stricter grant
|
||||
# ON TOP OF endpoints.command, so a remote reboot cannot ride the routine
|
||||
# toggle permission.
|
||||
elevated = elevated_permission_for(action)
|
||||
if elevated and not current_user.haspermission(elevated):
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
f'Action {action!r} requires the {elevated} permission',
|
||||
http_code=403)
|
||||
|
||||
if action not in ALLOWED_ACTIONS_FOR(assetid=asset_id):
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
f'Action not permitted by policy for this endpoint: {action!r}')
|
||||
|
||||
# Generic actions (service_* / process_*) must carry the subject to act on;
|
||||
# fixed-target actions (mtconnect_*) must not. Validate both directions so a
|
||||
# caller cannot send a targetless generic action (nothing to act on) or
|
||||
# smuggle a target onto a pinned action.
|
||||
target = (payload.get('target') or '').strip() or None
|
||||
targetkind = action_targetkind(action)
|
||||
if requires_target(action):
|
||||
if not target:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
f'Action {action!r} requires a target')
|
||||
elif target is not None:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
f'Action {action!r} does not accept a target')
|
||||
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
if state is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'No endpoint state for asset {asset_id}',
|
||||
http_code=404)
|
||||
|
||||
pending = (EndpointCommand.query
|
||||
.filter(EndpointCommand.assetid == asset_id,
|
||||
EndpointCommand.status.in_((
|
||||
EndpointCommand.STATUS_PENDING,
|
||||
EndpointCommand.STATUS_RUNNING)))
|
||||
.first())
|
||||
if pending is not None:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'A command is already in flight for this endpoint',
|
||||
http_code=409)
|
||||
|
||||
command = EndpointCommand(
|
||||
assetid=asset_id,
|
||||
action=action,
|
||||
target=target,
|
||||
targetkind=targetkind if target else None,
|
||||
status=EndpointCommand.STATUS_PENDING,
|
||||
requestedby=getattr(current_user, 'userid', None),
|
||||
)
|
||||
db.session.add(command)
|
||||
|
||||
AuditLog.log('created', 'EndpointCommand',
|
||||
entityname=action,
|
||||
details=f'action={action} target={target} asset={asset_id}')
|
||||
|
||||
db.session.commit()
|
||||
return success_response(command.to_dict(),
|
||||
message='Command queued', http_code=201)
|
||||
|
||||
|
||||
@endpoints_bp.route('/commands/next', methods=['GET'])
|
||||
def claim_next_command():
|
||||
"""Agent long-poll: claim the next pending command for a hostname.
|
||||
|
||||
Collector-token auth (not JWT). Holds the request open briefly and returns
|
||||
the single oldest pending command for the caller's endpoint, atomically
|
||||
marking it running so no other poll claims it. Returns 204 when nothing is
|
||||
queued within the hold window - the agent simply re-polls.
|
||||
"""
|
||||
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)
|
||||
|
||||
deadline = time.monotonic() + _CLAIM_HOLD_SECONDS
|
||||
while True:
|
||||
command = (EndpointCommand.query
|
||||
.filter_by(assetid=asset_id,
|
||||
status=EndpointCommand.STATUS_PENDING)
|
||||
.order_by(EndpointCommand.createdat.asc())
|
||||
.first())
|
||||
if command is not None:
|
||||
command.status = EndpointCommand.STATUS_RUNNING
|
||||
command.claimedat = _naive_utcnow()
|
||||
db.session.commit()
|
||||
return success_response({
|
||||
'endpointcommandid': command.endpointcommandid,
|
||||
'action': command.action,
|
||||
'target': command.target,
|
||||
'targetkind': command.targetkind,
|
||||
})
|
||||
|
||||
# Nothing queued: release the session so a concurrent enqueue is visible
|
||||
# on the next poll, wait a beat, then re-check until the hold expires.
|
||||
db.session.rollback()
|
||||
if time.monotonic() >= deadline:
|
||||
return ('', 204)
|
||||
time.sleep(_CLAIM_POLL_INTERVAL)
|
||||
|
||||
|
||||
@endpoints_bp.route('/commands/<int:command_id>/result', methods=['POST'])
|
||||
def report_command_result(command_id: int):
|
||||
"""Agent reports a claimed command's outcome (collector-token auth).
|
||||
|
||||
Finalizes the command row and, for MTConnect actions that observed the run
|
||||
state, caches it on the EndpointState so the WebUI toggle reflects reality
|
||||
between polls. Both writes land in one commit.
|
||||
"""
|
||||
error = _collector_auth_or_error()
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
command = db.session.get(EndpointCommand, command_id)
|
||||
if command is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'No command {command_id}', http_code=404)
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
succeeded = bool(payload.get('succeeded'))
|
||||
command.status = (EndpointCommand.STATUS_SUCCEEDED if succeeded
|
||||
else EndpointCommand.STATUS_FAILED)
|
||||
command.resultmessage = (payload.get('message') or '')[:2000] or None
|
||||
detail = payload.get('detail')
|
||||
command.resultjson = json.dumps(detail, separators=(',', ':')) \
|
||||
if detail is not None else None
|
||||
command.completedat = _naive_utcnow()
|
||||
|
||||
# Cache the observed MTConnect state so the toggle shows a real value. The
|
||||
# agent sends mtconnectrunning on any mtconnect_* result that probed it.
|
||||
if 'mtconnectrunning' in payload:
|
||||
state = EndpointState.query.filter_by(assetid=command.assetid).first()
|
||||
if state is not None:
|
||||
running = payload.get('mtconnectrunning')
|
||||
state.mtconnectrunning = bool(running) if running is not None else None
|
||||
state.mtconnectcheckedat = _naive_utcnow()
|
||||
|
||||
db.session.commit()
|
||||
return success_response(command.to_dict(), message='Result recorded')
|
||||
|
||||
198
plugins/endpoints/commands.py
Normal file
198
plugins/endpoints/commands.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Server-side allowlist of endpoint agent commands.
|
||||
|
||||
The WebUI never sends an arbitrary command string. It sends an allowlisted
|
||||
ACTION NAME; the concrete on-endpoint behavior (which script to run, how to stop
|
||||
a process) lives in the agent. This module is the single source of truth for
|
||||
which action names are legal and what they mean, so both the enqueue route and
|
||||
the settings/UI surface validate against the same set.
|
||||
|
||||
The structure anticipates per-PC-type allowlists: today every computer may run
|
||||
every action in COMMAND_ALLOWLIST, but ALLOWED_ACTIONS_FOR is the seam a later
|
||||
change swaps for a type-scoped lookup (inspection PCs vs shopfloor PCs).
|
||||
"""
|
||||
|
||||
# action name -> operator-facing metadata. label/description feed the UI; the
|
||||
# agent maps the action name to its local handler (see CommandExecutor).
|
||||
#
|
||||
# Two families of action:
|
||||
# * Named MTConnect actions - fixed target, no parameters. The original,
|
||||
# bay-specific controls kept for the inline services-panel toggle.
|
||||
# * Generic RMM actions (service_* / process_*) - the target is supplied by
|
||||
# the policy action's params ('target' = a Windows service name or a process
|
||||
# image like 'DncMain.exe'). This is what populates the Configuration Policy
|
||||
# builder with reusable controls that any scope (PC type or application) can
|
||||
# attach. The 'targetkind' hint tells the builder which target field to show.
|
||||
# NOTE: agent-side execution of the generic actions is not wired yet; the
|
||||
# server allowlist and policy plumbing land first (the whole feature is
|
||||
# flag-gated), so a generic action saved today is authored and gated but is
|
||||
# a no-op on the endpoint until the CommandExecutor handlers ship.
|
||||
COMMAND_ALLOWLIST = {
|
||||
'service_start': {
|
||||
'label': 'Start service',
|
||||
'description': 'Start a Windows service by name on the endpoint.',
|
||||
'targetkind': 'service',
|
||||
},
|
||||
'service_stop': {
|
||||
'label': 'Stop service',
|
||||
'description': 'Stop a running Windows service by name on the endpoint.',
|
||||
'targetkind': 'service',
|
||||
},
|
||||
'service_status': {
|
||||
'label': 'Check service',
|
||||
'description': 'Report whether a named Windows service is running.',
|
||||
'targetkind': 'service',
|
||||
},
|
||||
'process_start': {
|
||||
'label': 'Start process',
|
||||
'description': 'Launch a process by executable path on the endpoint.',
|
||||
'targetkind': 'process',
|
||||
},
|
||||
'process_stop': {
|
||||
'label': 'Stop process',
|
||||
'description': 'Stop a running process by image name on the endpoint.',
|
||||
'targetkind': 'process',
|
||||
},
|
||||
'process_status': {
|
||||
'label': 'Check process',
|
||||
'description': 'Report whether a named process is running.',
|
||||
'targetkind': 'process',
|
||||
},
|
||||
'mtconnect_start': {
|
||||
'label': 'Start MTConnect',
|
||||
'description': 'Run the MTConnect UDC agent start script on the endpoint.',
|
||||
},
|
||||
'mtconnect_stop': {
|
||||
'label': 'Stop MTConnect',
|
||||
'description': 'Stop the running MTConnect UDC agent on the endpoint.',
|
||||
},
|
||||
'mtconnect_status': {
|
||||
'label': 'Check MTConnect',
|
||||
'description': 'Report whether the MTConnect UDC agent is running.',
|
||||
},
|
||||
'system_restart': {
|
||||
'label': 'Restart computer',
|
||||
'description': 'Restart the endpoint computer immediately.',
|
||||
},
|
||||
'collect_now': {
|
||||
'label': 'Collect now',
|
||||
'description': 'Force the endpoint agent to report fresh telemetry off-cadence.',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Actions that demand a permission stricter than the base endpoints.command.
|
||||
# The enqueue route requires the caller to ALSO hold the mapped permission, so a
|
||||
# destructive action (a remote reboot) is a heavier grant than a routine toggle.
|
||||
ELEVATED_ACTION_PERMISSIONS = {
|
||||
'system_restart': 'endpoints.restart',
|
||||
}
|
||||
|
||||
|
||||
def elevated_permission_for(action):
|
||||
"""The extra permission an action requires, or None for routine actions."""
|
||||
return ELEVATED_ACTION_PERMISSIONS.get(action)
|
||||
|
||||
|
||||
# Maps a monitored services/processes row to the start/stop/status actions that
|
||||
# control it, so the services panel can render an inline toggle on exactly the
|
||||
# rows that have a registered command action. The key is the agent-reported
|
||||
# 'app' label; 'kind' pins the toggle to the authoritative row (for MTConnect
|
||||
# the agent.exe PROCESS is the source of truth - run.bat launches it and the
|
||||
# Windows service state is not reliable), so the service row stays badge-only.
|
||||
ROW_COMMAND_TARGETS = {
|
||||
('MTConnect', 'process'): {
|
||||
'start': 'mtconnect_start',
|
||||
'stop': 'mtconnect_stop',
|
||||
'status': 'mtconnect_status',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def row_actions_for(app, kind, name=None):
|
||||
"""Return the {start, stop, status, target, targetkind} map for a row.
|
||||
|
||||
Two paths:
|
||||
* A row registered in ROW_COMMAND_TARGETS (MTConnect) keeps its fixed,
|
||||
named actions with no wire target - agent.exe is the source of truth and
|
||||
the behavior is pinned in the agent.
|
||||
* Any other process/service row maps to the generic primitives
|
||||
(process_* / service_*) with the row's OWN reported name as the wire
|
||||
target. This is what lets a new app light up start/stop/status without
|
||||
an agent rebuild: the target rides the wire.
|
||||
|
||||
Rows with no usable kind return None and render as a plain status badge.
|
||||
"""
|
||||
if not app and not name:
|
||||
return None
|
||||
fixed = ROW_COMMAND_TARGETS.get((app, (kind or '').lower()))
|
||||
if fixed:
|
||||
return {**fixed, 'target': None, 'targetkind': None}
|
||||
normalized = (kind or '').lower()
|
||||
if normalized == 'process' and name:
|
||||
return {
|
||||
'start': 'process_start',
|
||||
'stop': 'process_stop',
|
||||
'status': 'process_status',
|
||||
'target': name,
|
||||
'targetkind': 'process',
|
||||
}
|
||||
if normalized == 'service' and name:
|
||||
return {
|
||||
'start': 'service_start',
|
||||
'stop': 'service_stop',
|
||||
'status': 'service_status',
|
||||
'target': name,
|
||||
'targetkind': 'service',
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def is_allowed_action(action):
|
||||
"""True when action is a known, allowlisted command name."""
|
||||
return action in COMMAND_ALLOWLIST
|
||||
|
||||
|
||||
def action_targetkind(action):
|
||||
"""The targetkind an action requires ('service'/'process'), or None.
|
||||
|
||||
Generic actions declare a targetkind in COMMAND_ALLOWLIST; fixed-target
|
||||
actions (mtconnect_*) declare none and must not carry a wire target.
|
||||
"""
|
||||
meta = COMMAND_ALLOWLIST.get(action) or {}
|
||||
return meta.get('targetkind')
|
||||
|
||||
|
||||
def requires_target(action):
|
||||
"""True when the action is generic and needs a wire target."""
|
||||
return action_targetkind(action) is not None
|
||||
|
||||
|
||||
def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
|
||||
"""The action names an endpoint may run.
|
||||
|
||||
Base allowlist is flat (every action for every computer). When the computers
|
||||
Configuration/Business Policy feature is enabled AND an assetid is given, the
|
||||
result is narrowed to the actions the endpoint's effective policy permits -
|
||||
this is the allowlist-gating half of the policy design. With the feature off
|
||||
(or no assetid), it returns the full base allowlist, so nothing changes until
|
||||
a site opts in. Policy resolution is imported lazily to keep this module free
|
||||
of a hard dependency on the computers plugin.
|
||||
"""
|
||||
base = set(COMMAND_ALLOWLIST.keys())
|
||||
if assetid is None:
|
||||
return base
|
||||
try:
|
||||
from plugins.computers.services.policyresolver import effective_policy_for
|
||||
except Exception:
|
||||
return base
|
||||
effective = effective_policy_for(assetid)
|
||||
allowed = effective.allowed_actions()
|
||||
if not allowed:
|
||||
# No policy spoke for this endpoint. With the feature off the resolver
|
||||
# returns empty and we fall back to the base allowlist (unchanged
|
||||
# behavior). This is intentionally permissive; deny-by-default would be
|
||||
# a separate, explicit setting.
|
||||
return base
|
||||
# Never widen past the code-level allowlist: policy can only gate within it.
|
||||
return base & allowed
|
||||
|
||||
46
plugins/endpoints/eventcatalog.py
Normal file
46
plugins/endpoints/eventcatalog.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Curated Windows event catalog: id -> human title and severity.
|
||||
|
||||
Single server-side source of truth shared by the recent-events asset panel and
|
||||
the stability dashboard card, so a 4625 reads as "Failed logon (warning)" in
|
||||
both places without either restating the mapping. The set of IDs here MUST stay
|
||||
in step with CURATED_EVENT_IDS in plugin.py; a test asserts they match.
|
||||
|
||||
Severity vocabulary matches the dashboard card renderer: 'critical', 'warning',
|
||||
'info'. The IDs marked critical are the ones an operator/tech should act on
|
||||
without being asked - unexpected shutdown, crash, filesystem corruption, SCM
|
||||
timeout. Logon activity is informational history unless it is a failure.
|
||||
"""
|
||||
|
||||
# id -> (title, severity, channel)
|
||||
EVENT_CATALOG = {
|
||||
4624: ('Successful logon', 'info', 'Security'),
|
||||
4625: ('Failed logon', 'warning', 'Security'),
|
||||
4634: ('Logoff', 'info', 'Security'),
|
||||
4672: ('Special privileges assigned', 'info', 'Security'),
|
||||
4697: ('Service installed', 'warning', 'Security'),
|
||||
4698: ('Scheduled task created', 'warning', 'Security'),
|
||||
41: ('Unexpected shutdown', 'critical', 'System'),
|
||||
4618: ('Monitored security event', 'warning', 'Security'),
|
||||
55: ('File system corruption', 'critical', 'System'),
|
||||
7011: ('Service timeout', 'critical', 'System'),
|
||||
10016: ('DCOM permission error', 'warning', 'System'),
|
||||
1001: ('Application crash', 'critical', 'Application'),
|
||||
}
|
||||
|
||||
# The IDs a tech should see surfaced fleet-wide on the dashboard stability card.
|
||||
CRITICAL_EVENT_IDS = tuple(
|
||||
eventid for eventid, (_t, severity, _c) in EVENT_CATALOG.items()
|
||||
if severity == 'critical'
|
||||
)
|
||||
|
||||
|
||||
def event_title(eventid):
|
||||
"""Human title for a curated event id, or a generic fallback."""
|
||||
entry = EVENT_CATALOG.get(eventid)
|
||||
return entry[0] if entry else 'Event {}'.format(eventid)
|
||||
|
||||
|
||||
def event_severity(eventid):
|
||||
"""Severity ('critical'|'warning'|'info') for a curated event id."""
|
||||
entry = EVENT_CATALOG.get(eventid)
|
||||
return entry[1] if entry else 'info'
|
||||
26
plugins/endpoints/frontend/routes.js
Normal file
26
plugins/endpoints/frontend/routes.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Endpoints plugin routes
|
||||
*
|
||||
* Groundwork for the fleet view. There is no endpoint-specific detail route
|
||||
* yet: rows link into the existing PC detail page (/pcs/:id) so telemetry
|
||||
* stays interoperable with the computers plugin instead of duplicating it.
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'endpoints',
|
||||
name: 'endpoints',
|
||||
component: () => import('./views/EndpointsList.vue'),
|
||||
meta: { plugin: 'endpoints' }
|
||||
},
|
||||
{
|
||||
path: 'settings/endpoints/agentkey',
|
||||
name: 'endpoints-agentkey-settings',
|
||||
component: () => import('./views/AgentKeySettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
|
||||
}
|
||||
]
|
||||
|
||||
// No full-screen routes: every view in this plugin renders inside
|
||||
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||
// without Rollup warning about a missing export.
|
||||
export const toplevel = []
|
||||
293
plugins/endpoints/frontend/views/AgentKeySettings.vue
Normal file
293
plugins/endpoints/frontend/views/AgentKeySettings.vue
Normal file
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Endpoints</h2>
|
||||
</div>
|
||||
|
||||
<p class="setting-description">
|
||||
Manage how shop-floor endpoint agents authenticate to this server, build
|
||||
the agent for this site, and set what it reports and how often.
|
||||
</p>
|
||||
|
||||
<!-- ====================================================================
|
||||
Key management
|
||||
================================================================= -->
|
||||
<section class="settings-section">
|
||||
<h3>Key management</h3>
|
||||
<p class="field-hint">
|
||||
The endpoint agent reports with a single managed key scoped to endpoint
|
||||
telemetry only. It is separate from every other collector, so rotating
|
||||
or revoking it stops all endpoint-agent traffic immediately without
|
||||
touching the PC or GE-Enforce collectors.
|
||||
</p>
|
||||
|
||||
<div class="card form-card">
|
||||
<div v-if="message" class="settings-success">{{ message }}</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div v-if="loading" class="field-hint">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="form-group">
|
||||
<label>Status</label>
|
||||
<p v-if="key" class="field-hint">
|
||||
Active key <code class="mono">{{ key.tokenprefix }}...</code>,
|
||||
created {{ formatDate(key.createddate) }}.
|
||||
<template v-if="key.lastusedat">
|
||||
Last used {{ formatDate(key.lastusedat) }}.
|
||||
</template>
|
||||
<template v-else>Not used yet.</template>
|
||||
</p>
|
||||
<p v-else class="field-hint">
|
||||
No endpoint agent key is active. Generate one to let agents report
|
||||
with a managed credential.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Bootstrap .env fallback</label>
|
||||
<p class="field-hint">
|
||||
<template v-if="envkeyfallback">
|
||||
<strong>On.</strong> The bootstrap COLLECTOR_API_KEY still
|
||||
authorizes endpoint reports. Generating a key turns this off so
|
||||
the managed key becomes the only credential.
|
||||
</template>
|
||||
<template v-else>
|
||||
<strong>Off.</strong> Only the managed key above authorizes
|
||||
endpoint reports, so revoking it is an instant kill switch.
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="btn btn-primary" @click="regenerate"
|
||||
:disabled="busy">
|
||||
{{ busy ? 'Working...' : (key ? 'Regenerate key' : 'Generate key') }}
|
||||
</button>
|
||||
<button v-if="key" class="btn btn-danger" @click="revoke"
|
||||
:disabled="busy">
|
||||
Revoke key
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="secret" class="card form-card secret-card">
|
||||
<h4>Save this key now</h4>
|
||||
<p class="field-hint">
|
||||
It will not be shown again. Configure it as the agent
|
||||
<code>X-API-Key</code> header value.
|
||||
</p>
|
||||
<p class="mono secret-value">{{ secret }}</p>
|
||||
<button class="btn btn-secondary" @click="copySecret">
|
||||
{{ copied ? 'Copied' : 'Copy to clipboard' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<h4>How it works</h4>
|
||||
<ul class="field-hint info-list">
|
||||
<li>
|
||||
<strong>Generate</strong> mints a fresh key and, on the first
|
||||
generation, turns off the bootstrap <code>.env</code> fallback so
|
||||
the managed key becomes the only accepted credential. The full
|
||||
secret is shown once - store it in the agent config as the
|
||||
<code>X-API-Key</code> header.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Regenerate</strong> is a hard cutover: the previous key is
|
||||
revoked in the same moment the new one is minted. Every deployed
|
||||
agent must be reconfigured with the new key before it can report
|
||||
again.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Revoke</strong> deactivates the key and leaves it off. All
|
||||
endpoint-agent reporting stops until you generate a new key. This is
|
||||
the kill switch.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h4>When to rotate</h4>
|
||||
<ul class="field-hint info-list">
|
||||
<li>
|
||||
<strong>An installer or agent left the secure environment.</strong>
|
||||
If a build carrying the key is copied off the shop floor - onto an
|
||||
engineer's laptop, a USB stick, or a machine that leaves the site -
|
||||
treat the key as compromised and regenerate immediately.
|
||||
</li>
|
||||
<li>
|
||||
<strong>A device is lost, stolen, or decommissioned</strong> while
|
||||
still holding the key, and you cannot confirm the key was wiped.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Staff offboarding.</strong> Someone who could read the key
|
||||
from a config or build pipeline is leaving; rotate as part of the
|
||||
offboarding checklist.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Scheduled hygiene.</strong> Rotate on a routine cadence
|
||||
(for example quarterly) so a silently leaked key has a bounded
|
||||
lifetime.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Suspicious traffic.</strong> The Fleet view shows reports
|
||||
from a host you do not recognize - revoke now, investigate, then
|
||||
regenerate and redeploy to known-good endpoints.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ====================================================================
|
||||
Build site agent (placeholder)
|
||||
================================================================= -->
|
||||
<section class="settings-section">
|
||||
<h3>Build site agent</h3>
|
||||
<div class="card form-card">
|
||||
<p class="field-hint">
|
||||
Produce an instance-locked agent executable stamped with this server's
|
||||
URL, the endpoint agent key, and the reporting cadence, so it deploys
|
||||
with no manual configuration on the client.
|
||||
</p>
|
||||
<p class="field-hint">
|
||||
<strong>Coming soon.</strong> The build pipeline is not wired up yet -
|
||||
the C# agent runtime is still under development. Generate a key above
|
||||
and configure it manually as the agent <code>X-API-Key</code> for now.
|
||||
</p>
|
||||
<button class="btn btn-secondary" type="button" disabled>
|
||||
Build agent (coming soon)
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ====================================================================
|
||||
Agent configuration (placeholder)
|
||||
================================================================= -->
|
||||
<section class="settings-section">
|
||||
<h3>Agent configuration</h3>
|
||||
<div class="card form-card">
|
||||
<p class="field-hint">
|
||||
Define what the agent collects and how often: reporting cadence,
|
||||
monitored processes and services, the curated Windows event list, and
|
||||
whether unknown hosts self-adopt on first contact. These settings are
|
||||
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.
|
||||
</p>
|
||||
<button class="btn btn-secondary" type="button" disabled>
|
||||
Edit configuration (coming soon)
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '@/api'
|
||||
|
||||
const loading = ref(true)
|
||||
const busy = ref(false)
|
||||
const key = ref(null)
|
||||
const envkeyfallback = ref(true)
|
||||
const secret = ref('')
|
||||
const copied = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return ''
|
||||
const parsed = new Date(value)
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString()
|
||||
}
|
||||
|
||||
function applyStatus(data) {
|
||||
key.value = data.key || null
|
||||
envkeyfallback.value = !!data.envkeyfallback
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await api.get('/endpoints/agentkey')
|
||||
applyStatus(response.data.data || {})
|
||||
} catch (loadError) {
|
||||
error.value = loadError.response?.status === 403
|
||||
? 'Only an administrator can manage the endpoint agent key'
|
||||
: 'Could not load the endpoint agent key status'
|
||||
console.error(loadError)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function regenerate() {
|
||||
if (key.value &&
|
||||
!window.confirm('Regenerate the key? The current key stops working '
|
||||
+ 'immediately and every agent must be reconfigured.')) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
message.value = ''
|
||||
error.value = ''
|
||||
secret.value = ''
|
||||
copied.value = false
|
||||
try {
|
||||
const response = await api.post('/endpoints/agentkey/regenerate')
|
||||
const data = response.data.data || {}
|
||||
applyStatus(data)
|
||||
secret.value = data.secret || ''
|
||||
message.value = 'Endpoint agent key generated.'
|
||||
} catch (regenError) {
|
||||
error.value = regenError.response?.data?.data?.error?.message
|
||||
|| 'Could not generate the endpoint agent key'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke() {
|
||||
if (!window.confirm('Revoke the key? All endpoint-agent reporting stops '
|
||||
+ 'until a new key is generated.')) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
message.value = ''
|
||||
error.value = ''
|
||||
secret.value = ''
|
||||
try {
|
||||
const response = await api.delete('/endpoints/agentkey')
|
||||
applyStatus(response.data.data || {})
|
||||
message.value = 'Endpoint agent key revoked.'
|
||||
} catch (revokeError) {
|
||||
error.value = revokeError.response?.data?.data?.error?.message
|
||||
|| 'Could not revoke the endpoint agent key'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copySecret() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(secret.value)
|
||||
copied.value = true
|
||||
} catch (copyError) {
|
||||
console.error(copyError)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono { font-family: monospace; word-break: break-all; }
|
||||
.form-card h3 { margin-top: 0; }
|
||||
.form-card h4 { margin-top: 1rem; margin-bottom: 0.25rem; }
|
||||
.form-card h4:first-child { margin-top: 0; }
|
||||
.button-row { display: flex; gap: 0.5rem; }
|
||||
.secret-card { border: 1px solid var(--color-warning, #b8860b); }
|
||||
.secret-value { padding: 0.5rem; background: var(--color-surface-2, #1e1e1e); }
|
||||
.settings-section { margin-bottom: 2rem; }
|
||||
.settings-section > h3 { margin-bottom: 0.25rem; }
|
||||
.info-list { margin: 0.25rem 0 0; padding-left: 1.25rem; }
|
||||
.info-list li { margin-bottom: 0.5rem; }
|
||||
</style>
|
||||
153
plugins/endpoints/frontend/views/EndpointsList.vue
Normal file
153
plugins/endpoints/frontend/views/EndpointsList.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Fleet</h2>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search endpoints..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<th>Logged-in User</th>
|
||||
<th>Uptime</th>
|
||||
<th>CPU</th>
|
||||
<th>Memory</th>
|
||||
<th>Last Reported</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in endpoints"
|
||||
:key="item.assetid"
|
||||
class="clickable-row"
|
||||
:class="{ disabled: !item.computerid }"
|
||||
@click="openPc(item)"
|
||||
>
|
||||
<td>{{ item.hostname || '-' }}</td>
|
||||
<td>{{ item.loggedinuser || '-' }}</td>
|
||||
<td>{{ item.uptimedisplay || '-' }}</td>
|
||||
<td>{{ formatPercent(item.cpupercent) }}</td>
|
||||
<td>{{ formatPercent(item.memorypercent) }}</td>
|
||||
<td>{{ formatTime(item.lastreportedat) }}</td>
|
||||
</tr>
|
||||
<tr v-if="endpoints.length === 0">
|
||||
<td colspan="6" style="text-align: center; color: var(--text-light);">
|
||||
No endpoints found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { endpointsApi } from '@/api'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const router = useRouter()
|
||||
const endpoints = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadEndpoints })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(() => {
|
||||
loadEndpoints()
|
||||
})
|
||||
|
||||
async function loadEndpoints() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: perPage.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
|
||||
const response = await endpointsApi.list(params)
|
||||
endpoints.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading endpoints:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Interoperate with PC Details instead of a bespoke detail page: a row only
|
||||
// navigates when it resolved to a computerid.
|
||||
function openPc(item) {
|
||||
if (item.computerid) {
|
||||
router.push(`/pcs/${item.computerid}`)
|
||||
}
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
if (value === null || value === undefined) return '-'
|
||||
return `${Math.round(value)}%`
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearch(search.value)
|
||||
loadEndpoints()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
setPage(p)
|
||||
loadEndpoints()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadEndpoints()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.clickable-row.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
21
plugins/endpoints/manifest.json
Normal file
21
plugins/endpoints/manifest.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "endpoints",
|
||||
"version": "0.1.0",
|
||||
"description": "Endpoint RMM telemetry: metrics, services, inventory, and curated Windows event records reported by the ShopDB agent",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": ["computers"],
|
||||
"core_version": ">=0.20.0,<1.0.0",
|
||||
"api_prefix": "/api/endpoints",
|
||||
"provides": {
|
||||
"features": [
|
||||
"endpoint_metrics",
|
||||
"service_state",
|
||||
"inventory_snapshot",
|
||||
"event_records"
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"report_interval_minutes": 15,
|
||||
"quiet_report_hours": 24
|
||||
}
|
||||
}
|
||||
15
plugins/endpoints/migrations/env.py
Normal file
15
plugins/endpoints/migrations/env.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Alembic environment for the endpoints plugin migration chain.
|
||||
|
||||
Delegates to the shared runner in shopdb.plugins.alembic_template, which filters
|
||||
the metadata to this plugin's tables and drives Alembic against the per-plugin
|
||||
version table alembic_version_endpoints. See ADR-008 for the ownership model.
|
||||
This plugin is NEW: its 0001 baseline really CREATES its tables, because the
|
||||
core chain never built them.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['PLUGIN_NAME'] = 'endpoints'
|
||||
|
||||
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
|
||||
|
||||
run_migrations()
|
||||
24
plugins/endpoints/migrations/script.py.mako
Normal file
24
plugins/endpoints/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,96 @@
|
||||
"""endpoints plugin baseline (real create).
|
||||
|
||||
A NEW plugin: the core chain never knew about endpointstates or endpointevents,
|
||||
so this per-plugin chain is their sole authoritative creator.
|
||||
|
||||
Emits explicit Alembic ops rather than using the create_plugin_tables helper:
|
||||
the helper builds a per-plugin MetaData filtered to the plugin's own tables, so
|
||||
the foreign key to the core assets table cannot resolve at CreateTable-compile
|
||||
time (NoReferencedTableError). Tables inherit the connection's default charset,
|
||||
matching how the core chain creates its own.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
from shopdb.plugins.alembic_template import (
|
||||
create_index_if_missing,
|
||||
create_table_if_missing,
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0001baseline'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
create_table_if_missing(
|
||||
'endpointstates',
|
||||
sa.Column('endpointstateid', sa.Integer(), nullable=False),
|
||||
sa.Column('assetid', sa.Integer(), nullable=False),
|
||||
sa.Column('hostname', sa.String(length=100), nullable=True),
|
||||
sa.Column('loggedinuser', sa.String(length=100), nullable=True),
|
||||
sa.Column('agentversion', sa.String(length=40), nullable=True),
|
||||
sa.Column('lastboottime', sa.DateTime(), nullable=True),
|
||||
sa.Column('uptimeseconds', sa.BigInteger(), nullable=True),
|
||||
sa.Column('idleseconds', sa.BigInteger(), nullable=True),
|
||||
sa.Column('cpupercent', sa.Float(), nullable=True),
|
||||
sa.Column('memorypercent', sa.Float(), nullable=True),
|
||||
sa.Column('servicesjson', sa.Text(length=16777215), nullable=True),
|
||||
sa.Column('portsjson', sa.Text(length=16777215), nullable=True),
|
||||
sa.Column('interfacesjson', sa.Text(length=16777215), nullable=True),
|
||||
sa.Column('scheduledtasksjson', sa.Text(length=16777215),
|
||||
nullable=True),
|
||||
sa.Column('lastreportedat', sa.DateTime(), nullable=True),
|
||||
sa.Column('createdat', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('endpointstateid'),
|
||||
sa.UniqueConstraint('assetid'),
|
||||
)
|
||||
create_index_if_missing('ix_endpointstates_assetid',
|
||||
'endpointstates', ['assetid'])
|
||||
create_index_if_missing('ix_endpointstates_lastreportedat',
|
||||
'endpointstates', ['lastreportedat'])
|
||||
|
||||
create_table_if_missing(
|
||||
'endpointevents',
|
||||
sa.Column('endpointeventid', sa.Integer(), nullable=False),
|
||||
sa.Column('assetid', sa.Integer(), nullable=False),
|
||||
sa.Column('eventid', sa.Integer(), nullable=False),
|
||||
sa.Column('eventchannel', sa.String(length=50), nullable=True),
|
||||
sa.Column('eventsource', sa.String(length=255), nullable=True),
|
||||
sa.Column('eventtime', sa.DateTime(), nullable=True),
|
||||
sa.Column('eventmessage', sa.Text(), nullable=True),
|
||||
sa.Column('payloadjson', sa.Text(length=16777215), nullable=True),
|
||||
sa.Column('recordedat', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('endpointeventid'),
|
||||
)
|
||||
create_index_if_missing('ix_endpointevents_assetid', 'endpointevents',
|
||||
['assetid'])
|
||||
create_index_if_missing('ix_endpointevents_eventid', 'endpointevents',
|
||||
['eventid'])
|
||||
create_index_if_missing('ix_endpointevents_eventtime', 'endpointevents',
|
||||
['eventtime'])
|
||||
# Alerting/history reads by "this endpoint's events over time", so the
|
||||
# composite is the index that actually gets used.
|
||||
create_index_if_missing('idx_endpointevents_assetevent', 'endpointevents',
|
||||
['assetid', 'eventtime'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('idx_endpointevents_assetevent',
|
||||
table_name='endpointevents')
|
||||
op.drop_index('ix_endpointevents_eventtime', table_name='endpointevents')
|
||||
op.drop_index('ix_endpointevents_eventid', table_name='endpointevents')
|
||||
op.drop_index('ix_endpointevents_assetid', table_name='endpointevents')
|
||||
op.drop_table('endpointevents')
|
||||
|
||||
op.drop_index('ix_endpointstates_lastreportedat',
|
||||
table_name='endpointstates')
|
||||
op.drop_index('ix_endpointstates_assetid', table_name='endpointstates')
|
||||
op.drop_table('endpointstates')
|
||||
@@ -0,0 +1,84 @@
|
||||
"""endpoint commands queue + cached MTConnect state.
|
||||
|
||||
Adds the endpointcommands queue (one WebUI-issued, server-allowlisted action
|
||||
per row, claimed and finalized by the agent long-poll) and two cached MTConnect
|
||||
run-state columns on endpointstates so the WebUI toggle can show a real state
|
||||
between command polls.
|
||||
|
||||
Idempotent: create_table_if_missing guards the new table, and the column adds
|
||||
check the live schema first so a db.create_all() site (which already built the
|
||||
columns) re-runs clean on strict MySQL 8.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
from shopdb.plugins.alembic_template import (
|
||||
create_index_if_missing,
|
||||
create_table_if_missing,
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0002commands'
|
||||
down_revision = 'endpoints0001baseline'
|
||||
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 not _has_column('endpointstates', 'mtconnectrunning'):
|
||||
op.add_column('endpointstates',
|
||||
sa.Column('mtconnectrunning', sa.Boolean(),
|
||||
nullable=True))
|
||||
if not _has_column('endpointstates', 'mtconnectcheckedat'):
|
||||
op.add_column('endpointstates',
|
||||
sa.Column('mtconnectcheckedat', sa.DateTime(),
|
||||
nullable=True))
|
||||
|
||||
create_table_if_missing(
|
||||
'endpointcommands',
|
||||
sa.Column('endpointcommandid', sa.Integer(), nullable=False),
|
||||
sa.Column('assetid', sa.Integer(), nullable=False),
|
||||
sa.Column('action', sa.String(length=64), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('requestedby', sa.Integer(), nullable=True),
|
||||
sa.Column('resultmessage', sa.Text(), nullable=True),
|
||||
sa.Column('resultjson', sa.Text(length=16777215), nullable=True),
|
||||
sa.Column('createdat', sa.DateTime(), nullable=False),
|
||||
sa.Column('claimedat', sa.DateTime(), nullable=True),
|
||||
sa.Column('completedat', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('endpointcommandid'),
|
||||
)
|
||||
create_index_if_missing('ix_endpointcommands_assetid',
|
||||
'endpointcommands', ['assetid'])
|
||||
create_index_if_missing('ix_endpointcommands_status',
|
||||
'endpointcommands', ['status'])
|
||||
create_index_if_missing('idx_endpointcommands_assetstatus',
|
||||
'endpointcommands', ['assetid', 'status'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('idx_endpointcommands_assetstatus',
|
||||
table_name='endpointcommands')
|
||||
op.drop_index('ix_endpointcommands_status',
|
||||
table_name='endpointcommands')
|
||||
op.drop_index('ix_endpointcommands_assetid',
|
||||
table_name='endpointcommands')
|
||||
op.drop_table('endpointcommands')
|
||||
|
||||
if _has_column('endpointstates', 'mtconnectcheckedat'):
|
||||
op.drop_column('endpointstates', 'mtconnectcheckedat')
|
||||
if _has_column('endpointstates', 'mtconnectrunning'):
|
||||
op.drop_column('endpointstates', 'mtconnectrunning')
|
||||
@@ -0,0 +1,50 @@
|
||||
"""endpoint command target for generic service/process actions.
|
||||
|
||||
Adds nullable target and targetkind columns to endpointcommands so generic
|
||||
service_* / process_* actions can carry their subject (a Windows service name or
|
||||
a process image like 'DncMain.exe') over the wire. Fixed-target actions
|
||||
(mtconnect_*) leave both null. This is the schema half of making the agent
|
||||
data-driven: a site builds the agent once and every future policy/app is server
|
||||
data, no agent redistribution.
|
||||
|
||||
Idempotent: each add checks the live schema first so a db.create_all() site
|
||||
(which already built the columns) re-runs clean on strict MySQL 8.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0003cmdtarget'
|
||||
down_revision = 'endpoints0002commands'
|
||||
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 not _has_column('endpointcommands', 'target'):
|
||||
op.add_column('endpointcommands',
|
||||
sa.Column('target', sa.String(length=255),
|
||||
nullable=True))
|
||||
if not _has_column('endpointcommands', 'targetkind'):
|
||||
op.add_column('endpointcommands',
|
||||
sa.Column('targetkind', sa.String(length=16),
|
||||
nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
if _has_column('endpointcommands', 'targetkind'):
|
||||
op.drop_column('endpointcommands', 'targetkind')
|
||||
if _has_column('endpointcommands', 'target'):
|
||||
op.drop_column('endpointcommands', 'target')
|
||||
5
plugins/endpoints/models/__init__.py
Normal file
5
plugins/endpoints/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Endpoints plugin models."""
|
||||
|
||||
from .endpoint import EndpointState, EndpointEvent, EndpointCommand
|
||||
|
||||
__all__ = ['EndpointState', 'EndpointEvent', 'EndpointCommand']
|
||||
248
plugins/endpoints/models/endpoint.py
Normal file
248
plugins/endpoints/models/endpoint.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""Endpoint RMM telemetry models.
|
||||
|
||||
Two shapes, one per concern:
|
||||
|
||||
EndpointState The cached CURRENT snapshot for an asset - one row per asset,
|
||||
overwritten in place on every agent report. Holds the fast-
|
||||
moving metrics (uptime, idle, last boot, logged-in user) and
|
||||
the latest inventory snapshots (services/processes, open ports,
|
||||
interfaces, scheduled tasks) as serialized JSON. This is the
|
||||
cached-current-value half of the ledger pattern: it moves in
|
||||
the SAME commit as any EndpointEvent rows the report carries.
|
||||
|
||||
EndpointEvent Append-only record of a curated Windows Event Log entry. The
|
||||
agent only forwards a fixed catalog of IDs (logon/logoff,
|
||||
privilege use, service install, unexpected shutdown, DCOM,
|
||||
crash) so this stays a readable security/stability history
|
||||
rather than a raw event firehose.
|
||||
|
||||
Both link to the core Asset via assetid (FK, ON DELETE CASCADE), so an endpoint
|
||||
is the same physical thing as its Computer/Asset - no duplicate identity. The
|
||||
agent reports a hostname; the plugin resolves it to the existing Computer.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
def _utciso(value):
|
||||
"""Naive-UTC datetime -> ISO string marked UTC, or None.
|
||||
|
||||
Every datetime this plugin stores is naive UTC. Serializing without the Z
|
||||
leaves the receiver to guess, and JavaScript guesses browser-local.
|
||||
"""
|
||||
return value.isoformat() + 'Z' if value else None
|
||||
|
||||
|
||||
class EndpointState(db.Model):
|
||||
"""Latest reported state for a single endpoint (one row per asset)."""
|
||||
|
||||
__tablename__ = 'endpointstates'
|
||||
|
||||
endpointstateid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# The asset this state belongs to. Unique: exactly one current snapshot per
|
||||
# endpoint. Resolved from the reported hostname against the computers plugin.
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Read-only view of the owning asset. No cascade or backref: the asset side
|
||||
# must not gain a dependency on this plugin.
|
||||
asset = db.relationship('Asset', lazy='joined', viewonly=True)
|
||||
|
||||
hostname = db.Column(db.String(100), nullable=True)
|
||||
loggedinuser = db.Column(db.String(100), nullable=True)
|
||||
agentversion = db.Column(db.String(40), nullable=True)
|
||||
|
||||
# Fast-moving metrics
|
||||
lastboottime = db.Column(db.DateTime, nullable=True)
|
||||
uptimeseconds = db.Column(db.BigInteger, nullable=True)
|
||||
idleseconds = db.Column(db.BigInteger, nullable=True)
|
||||
cpupercent = db.Column(db.Float, nullable=True)
|
||||
memorypercent = db.Column(db.Float, nullable=True)
|
||||
|
||||
# Latest inventory snapshots, serialized JSON (MEDIUMTEXT). Kept as the
|
||||
# newest snapshot rather than one row per sample: the agent reports the full
|
||||
# list each cycle and history of these lists is not what operators ask for.
|
||||
servicesjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
portsjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
interfacesjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
scheduledtasksjson = 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).
|
||||
mtconnectrunning = db.Column(db.Boolean, nullable=True)
|
||||
mtconnectcheckedat = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# When the agent last successfully reported. There is no heartbeat separate
|
||||
# from the report, so silence past the quiet-hours window is the only
|
||||
# evidence an endpoint has stopped checking in.
|
||||
lastreportedat = db.Column(db.DateTime, nullable=True, index=True)
|
||||
|
||||
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EndpointState {self.hostname or self.assetid}>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'endpointstateid': self.endpointstateid,
|
||||
'assetid': self.assetid,
|
||||
'hostname': self.hostname,
|
||||
'loggedinuser': self.loggedinuser,
|
||||
'agentversion': self.agentversion,
|
||||
'lastboottime': _utciso(self.lastboottime),
|
||||
'uptimeseconds': self.uptimeseconds,
|
||||
'idleseconds': self.idleseconds,
|
||||
'cpupercent': self.cpupercent,
|
||||
'memorypercent': self.memorypercent,
|
||||
'mtconnectrunning': self.mtconnectrunning,
|
||||
'mtconnectcheckedat': _utciso(self.mtconnectcheckedat),
|
||||
'lastreportedat': _utciso(self.lastreportedat),
|
||||
'createdat': _utciso(self.createdat),
|
||||
}
|
||||
|
||||
|
||||
class EndpointCommand(db.Model):
|
||||
"""A queued operator command targeting one endpoint agent.
|
||||
|
||||
The WebUI enqueues a named action (server allowlist only, never an arbitrary
|
||||
command string) for an asset. The agent claims the next pending command on
|
||||
its long-poll, executes it locally, and reports the outcome back, which
|
||||
moves the row to a terminal status in the same request. This is a small
|
||||
at-most-one-in-flight queue, not a general job system.
|
||||
"""
|
||||
|
||||
__tablename__ = 'endpointcommands'
|
||||
|
||||
# Lifecycle: pending -> running -> succeeded|failed. A command is claimed
|
||||
# (pending -> running) by exactly one agent poll, then finalized by its
|
||||
# result post.
|
||||
STATUS_PENDING = 'pending'
|
||||
STATUS_RUNNING = 'running'
|
||||
STATUS_SUCCEEDED = 'succeeded'
|
||||
STATUS_FAILED = 'failed'
|
||||
|
||||
endpointcommandid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
asset = db.relationship('Asset', lazy='joined', viewonly=True)
|
||||
|
||||
# The allowlisted action name (for example 'mtconnect_start'). Validated
|
||||
# against the server allowlist at enqueue time, so only known names land.
|
||||
action = db.Column(db.String(64), nullable=False)
|
||||
|
||||
# Generic actions (service_* / process_*) carry their subject over the wire:
|
||||
# target is a Windows service name or a process image (for example
|
||||
# 'DncMain.exe'); targetkind is 'service' or 'process'. Fixed-target actions
|
||||
# (mtconnect_*) leave both null. This is what lets a site build the agent
|
||||
# once and drive every future policy/app purely from server data.
|
||||
target = db.Column(db.String(255), nullable=True)
|
||||
targetkind = db.Column(db.String(16), nullable=True)
|
||||
|
||||
status = db.Column(db.String(20), nullable=False,
|
||||
default=STATUS_PENDING, index=True)
|
||||
|
||||
# Who queued it (userid) for audit; the agent never sees this.
|
||||
requestedby = db.Column(db.Integer, nullable=True)
|
||||
|
||||
# Agent-reported outcome. resultjson holds structured detail (for example
|
||||
# the observed MTConnect running state); resultmessage is a short summary.
|
||||
resultmessage = db.Column(db.Text, nullable=True)
|
||||
resultjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
claimedat = db.Column(db.DateTime, nullable=True)
|
||||
completedat = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_endpointcommands_assetstatus', 'assetid', 'status'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EndpointCommand {self.action} asset={self.assetid} {self.status}>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'endpointcommandid': self.endpointcommandid,
|
||||
'assetid': self.assetid,
|
||||
'action': self.action,
|
||||
'target': self.target,
|
||||
'targetkind': self.targetkind,
|
||||
'status': self.status,
|
||||
'requestedby': self.requestedby,
|
||||
'resultmessage': self.resultmessage,
|
||||
'createdat': _utciso(self.createdat),
|
||||
'claimedat': _utciso(self.claimedat),
|
||||
'completedat': _utciso(self.completedat),
|
||||
}
|
||||
|
||||
|
||||
class EndpointEvent(db.Model):
|
||||
"""A single curated Windows Event Log record forwarded by the agent."""
|
||||
|
||||
__tablename__ = 'endpointevents'
|
||||
|
||||
endpointeventid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
asset = db.relationship('Asset', lazy='joined', viewonly=True)
|
||||
|
||||
# The Windows Event ID (for example 4625 failed logon, 41 unexpected
|
||||
# shutdown, 1001 application crash). Indexed because alerting queries filter
|
||||
# on it.
|
||||
eventid = db.Column(db.Integer, nullable=False, index=True)
|
||||
|
||||
# Event Log channel the record came from: Security, System, Application.
|
||||
eventchannel = db.Column(db.String(50), nullable=True)
|
||||
eventsource = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# When the event happened on the endpoint (from the record), not when it
|
||||
# reached ShopDB.
|
||||
eventtime = db.Column(db.DateTime, nullable=True, index=True)
|
||||
|
||||
eventmessage = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Structured fields the agent extracted for this ID (for example the target
|
||||
# account on a 4625). Advisory index only, not part of dedup.
|
||||
payloadjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
recordedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_endpointevents_assetevent', 'assetid', 'eventtime'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EndpointEvent {self.eventid} asset={self.assetid}>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'endpointeventid': self.endpointeventid,
|
||||
'assetid': self.assetid,
|
||||
'eventid': self.eventid,
|
||||
'eventchannel': self.eventchannel,
|
||||
'eventsource': self.eventsource,
|
||||
'eventtime': _utciso(self.eventtime),
|
||||
'eventmessage': self.eventmessage,
|
||||
'recordedat': _utciso(self.recordedat),
|
||||
}
|
||||
596
plugins/endpoints/plugin.py
Normal file
596
plugins/endpoints/plugin.py
Normal file
@@ -0,0 +1,596 @@
|
||||
"""Endpoints plugin main class.
|
||||
|
||||
Endpoint RMM telemetry reported by the ShopDB agent. Accepts collector input
|
||||
over HTTPS per ADR-006: the agent posts a per-host payload to
|
||||
POST /api/collector/endpoints, the plugin resolves the reported hostname to an
|
||||
existing computer asset, overwrites that asset's cached EndpointState, and
|
||||
appends any curated Windows events the report carried - all in one commit, so
|
||||
the cached snapshot and the events it summarizes never disagree.
|
||||
|
||||
Identity is the hostname, resolved against the computers plugin. An unresolved
|
||||
hostname is reported as a ValueError (surfaced to the caller as a validation
|
||||
error), never silently dropped: an unknown host is a real gap between the fleet
|
||||
and ShopDB, the same discipline the backups collector uses for machine numbers.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db
|
||||
|
||||
from .api import endpoints_bp
|
||||
from .eventcatalog import EVENT_CATALOG
|
||||
from .models import EndpointState, EndpointEvent, EndpointCommand
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The fixed catalog of Windows Event IDs the agent forwards. Derived from
|
||||
# EVENT_CATALOG (eventcatalog.py) so the ingest filter and the display titles
|
||||
# can never drift. IDs outside this set are ignored on ingest rather than
|
||||
# stored, keeping endpointevents a readable security/stability history instead
|
||||
# of a raw event firehose.
|
||||
CURATED_EVENT_IDS = frozenset(EVENT_CATALOG)
|
||||
|
||||
|
||||
def _parse_dt(value):
|
||||
"""Best-effort parse of an ISO-8601 string to naive UTC, or None."""
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.replace(tzinfo=None)
|
||||
text = str(value).strip().replace('Z', '+00:00')
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is not None:
|
||||
parsed = parsed.astimezone(tz=None).replace(tzinfo=None)
|
||||
return parsed
|
||||
|
||||
|
||||
def _as_json(value):
|
||||
"""Serialize a list/dict to JSON text, or None when nothing was sent."""
|
||||
if value is None:
|
||||
return None
|
||||
return json.dumps(value, separators=(',', ':'), sort_keys=True)
|
||||
|
||||
|
||||
class EndpointsPlugin(BasePlugin):
|
||||
"""Endpoint RMM telemetry ingest plus read/dashboard surface."""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
manifest_path = Path(__file__).parent / 'manifest.json'
|
||||
if manifest_path.exists():
|
||||
with open(manifest_path, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'endpoints'),
|
||||
version=self._manifest.get('version', '0.1.0'),
|
||||
description=self._manifest.get(
|
||||
'description', 'Endpoint RMM telemetry'),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', ['computers']),
|
||||
core_version=self._manifest.get('core_version', '>=0.20.0,<1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/endpoints'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
return endpoints_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
return [EndpointState, EndpointEvent, EndpointCommand]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""RBAC for the human-facing read routes.
|
||||
|
||||
View only. Ingest is not covered here: the collector path authorizes on
|
||||
a collector.ingest-scoped service token, not a user JWT. There is no
|
||||
write/delete permission because operators do not edit telemetry - the
|
||||
agent is the sole author, and history is pruned by policy, not by hand.
|
||||
"""
|
||||
return [
|
||||
('endpoints.view', 'View endpoint telemetry', 'endpoints'),
|
||||
# Issue an allowlisted command to an endpoint agent (for example the
|
||||
# MTConnect toggle). Separate from endpoints.view because acting on a
|
||||
# shop-floor PC is a heavier grant than reading its telemetry.
|
||||
('endpoints.command',
|
||||
'Send allowlisted commands to endpoint agents', 'endpoints'),
|
||||
# Elevated grant for destructive endpoint actions (system_restart).
|
||||
# Required IN ADDITION to endpoints.command by the enqueue route, so a
|
||||
# remote reboot is a strictly heavier grant than a routine toggle.
|
||||
('endpoints.restart',
|
||||
'Restart endpoint computers (destructive)', 'endpoints'),
|
||||
# Collector service scope for the endpoint agent. A managed token
|
||||
# scoped to ONLY this authorizes the endpoints collector and nothing
|
||||
# else, so rotating/revoking it stops endpoint-agent traffic without
|
||||
# touching computers/geenforce collectors. Minted from the settings
|
||||
# UI (see get_settings_cards). Admins hold it via the catalog.
|
||||
('collector.ingest.endpoints',
|
||||
'Submit endpoint agent telemetry (fleet reporting)', 'collector'),
|
||||
]
|
||||
|
||||
def get_settings_defaults(self) -> List[Dict]:
|
||||
"""Settings owned by the endpoints agent-key kill switch.
|
||||
|
||||
`envkeyfallback` gates the bootstrap .env collector key: while true the
|
||||
old COLLECTOR_API_KEY[_ENDPOINTS] still authorizes (back-compat, and the
|
||||
only path before a UI key exists). The regenerate flow flips it false so
|
||||
the .env key stops working and the managed token is the sole credential,
|
||||
making revocation an instant kill switch. `agentkeyprovisioned` records
|
||||
that a UI-managed key has been minted so the settings page can render
|
||||
the rotate/revoke state instead of the first-run generate state.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'key': 'plugin.endpoints.envkeyfallback',
|
||||
'value': 'true',
|
||||
'valuetype': 'boolean',
|
||||
'category': 'plugin.endpoints',
|
||||
'description': 'Accept the bootstrap .env collector key for '
|
||||
'endpoints. Turned off once a UI-managed agent '
|
||||
'key is provisioned so revocation is a real kill '
|
||||
'switch.',
|
||||
},
|
||||
{
|
||||
'key': 'plugin.endpoints.agentkeyprovisioned',
|
||||
'value': 'false',
|
||||
'valuetype': 'boolean',
|
||||
'category': 'plugin.endpoints',
|
||||
'description': 'True once an operator has minted a UI-managed '
|
||||
'endpoint agent key from the settings page.',
|
||||
},
|
||||
]
|
||||
|
||||
def get_settings_cards(self) -> List[Dict]:
|
||||
"""The endpoints management card on the global settings page.
|
||||
|
||||
A settings card rather than a docs page because it is the operator's
|
||||
control surface: manage the agent key (the kill switch), build the site
|
||||
agent, and configure what it reports. Admin only - key management mints
|
||||
a collector service credential.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'group': 'Endpoints',
|
||||
'to': '/settings/endpoints/agentkey',
|
||||
'icon': 'key',
|
||||
'title': 'Endpoints',
|
||||
'description': 'Manage the endpoint agent key, build the site '
|
||||
'agent, and configure what it reports.',
|
||||
'position': 27,
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
"""Sidebar entry for the fleet list view.
|
||||
|
||||
Sits just after the PCs item (position 15) because the fleet view is
|
||||
a telemetry lens over the same computers.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'name': 'Fleet',
|
||||
'icon': 'activity',
|
||||
'route': '/endpoints',
|
||||
'position': 16,
|
||||
'permission': 'endpoints.view',
|
||||
'section': 'fleet',
|
||||
},
|
||||
]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
logger.info('Endpoints plugin initialized (v%s)', self.meta.version)
|
||||
|
||||
# ---- ADR-010 UI hooks -----------------------------------------------
|
||||
|
||||
def get_asset_panels(self) -> List[Dict]:
|
||||
"""Per-PC detail panels, computer assets only.
|
||||
|
||||
Panels feeding the generic renderer: the current status snapshot
|
||||
(keyvalue), monitored services and processes split into two list cards
|
||||
that share one services snapshot (filtered by kind), the most recent
|
||||
curated events (list), and the network interfaces (table). Each is a
|
||||
single-asset indexed read, so the cost is the same profile as the
|
||||
warranty/backups panels. Panels hide themselves when their endpoint
|
||||
returns nothing.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'id': 'endpoints-status',
|
||||
'title': 'Endpoint status',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/status',
|
||||
'render': 'keyvalue',
|
||||
'empty': 'No agent report yet.',
|
||||
'position': 10,
|
||||
},
|
||||
{
|
||||
'id': 'endpoints-tracked-services',
|
||||
'title': 'Tracked Services',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/services',
|
||||
'render': 'list',
|
||||
'collapsible': True,
|
||||
'filter': {'key': 'kind', 'equals': 'service'},
|
||||
'status': {
|
||||
'key': 'statecolor',
|
||||
'levels': {
|
||||
'green': 'ok',
|
||||
'yellow': 'warning',
|
||||
'red': 'critical',
|
||||
},
|
||||
},
|
||||
'map': {
|
||||
'title': 'name',
|
||||
'badge': {'label': 'statelabel', 'color': 'statecolor'},
|
||||
'action': 'action',
|
||||
},
|
||||
'commandendpoint': '/api/endpoints/{assetid}/commands',
|
||||
'permission': 'endpoints.command',
|
||||
'empty': 'No monitored services reported.',
|
||||
'position': 12,
|
||||
},
|
||||
{
|
||||
'id': 'endpoints-tracked-processes',
|
||||
'title': 'Tracked Processes',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/services',
|
||||
'render': 'list',
|
||||
'collapsible': True,
|
||||
'filter': {'key': 'kind', 'equals': 'process'},
|
||||
'status': {
|
||||
'key': 'statecolor',
|
||||
'levels': {
|
||||
'green': 'ok',
|
||||
'yellow': 'warning',
|
||||
'red': 'critical',
|
||||
},
|
||||
},
|
||||
'map': {
|
||||
'title': 'name',
|
||||
'badge': {'label': 'statelabel', 'color': 'statecolor'},
|
||||
'action': 'action',
|
||||
},
|
||||
'commandendpoint': '/api/endpoints/{assetid}/commands',
|
||||
'permission': 'endpoints.command',
|
||||
'empty': 'No monitored processes reported.',
|
||||
'position': 13,
|
||||
},
|
||||
{
|
||||
'id': 'endpoints-events',
|
||||
'title': 'Recent events',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/recentevents',
|
||||
'render': 'list',
|
||||
'collapsible': True,
|
||||
'status': {
|
||||
'key': 'severity',
|
||||
'levels': {
|
||||
'info': 'ok',
|
||||
'warning': 'warning',
|
||||
'critical': 'critical',
|
||||
},
|
||||
},
|
||||
'map': {
|
||||
'title': 'title',
|
||||
'badge': {
|
||||
'label': 'severity',
|
||||
'labelmap': {
|
||||
'critical': 'Critical',
|
||||
'warning': 'Warning',
|
||||
'info': 'Info',
|
||||
},
|
||||
},
|
||||
'meta': [
|
||||
{'key': 'eventtime', 'label': 'At', 'format': 'date'},
|
||||
],
|
||||
},
|
||||
'empty': 'No recent events.',
|
||||
'collapsedDefault': True,
|
||||
'position': 90,
|
||||
},
|
||||
{
|
||||
'id': 'endpoints-interfaces',
|
||||
'title': 'Network interfaces',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/interfaces',
|
||||
'render': 'table',
|
||||
'collapsible': True,
|
||||
'columns': [
|
||||
{'key': 'alias', 'label': 'Adapter'},
|
||||
{'key': 'ipv4', 'label': 'IPv4'},
|
||||
{'key': 'mac', 'label': 'MAC'},
|
||||
{'key': 'gateway', 'label': 'Gateway'},
|
||||
{'key': 'subnet', 'label': 'Subnet'},
|
||||
{'key': 'dhcp', 'label': 'Assignment'},
|
||||
{'key': 'role', 'label': 'Role'},
|
||||
],
|
||||
'empty': 'No network interfaces reported.',
|
||||
'position': 16,
|
||||
},
|
||||
]
|
||||
|
||||
def get_dashboard_widgets(self) -> List[Dict]:
|
||||
"""Fleet-wide exception cards. Run once on dashboard load, not per PC.
|
||||
|
||||
Both cards hide when empty and cap their rows, so a healthy fleet adds
|
||||
nothing to the dashboard and an unhealthy one adds a bounded list.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'id': 'endpoints-quiet',
|
||||
'viewall': '/pcs',
|
||||
'title': 'Endpoints gone quiet',
|
||||
'endpoint': '/api/endpoints/dashboard/quiet',
|
||||
'render': 'exceptions',
|
||||
'severity': 'warning',
|
||||
'permission': 'endpoints.view',
|
||||
'empty': 'hide',
|
||||
'position': 22,
|
||||
'map': {
|
||||
'title': 'hostname',
|
||||
'detail': '',
|
||||
'meta': [{'key': 'quietdays', 'label': 'silent',
|
||||
'suffix': ' days'}],
|
||||
'link': '/pcs/{computerid}',
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': 'endpoints-stability',
|
||||
'viewall': '/pcs',
|
||||
'title': 'Endpoints with crashes or shutdowns',
|
||||
'endpoint': '/api/endpoints/dashboard/stability',
|
||||
'render': 'exceptions',
|
||||
'severity': 'critical',
|
||||
'permission': 'endpoints.view',
|
||||
'empty': 'hide',
|
||||
'position': 16,
|
||||
'map': {
|
||||
'title': 'hostname',
|
||||
'detail': '',
|
||||
'meta': [{'key': 'eventcount', 'suffix': ' events (48h)'}],
|
||||
'link': '/pcs/{computerid}',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# ---- ADR-006 collector contract -------------------------------------
|
||||
|
||||
def get_collector_schema(self) -> Optional[Dict]:
|
||||
return {
|
||||
'identityfield': 'hostname',
|
||||
'fields': {
|
||||
'type': 'object',
|
||||
'required': ['hostname'],
|
||||
'properties': {
|
||||
'hostname': {
|
||||
'type': 'string',
|
||||
'description': 'Endpoint hostname. Resolved against '
|
||||
'the computers plugin (computers.hostname).',
|
||||
},
|
||||
'adopt': {
|
||||
'type': 'boolean',
|
||||
'description': 'First-contact only. When true and the '
|
||||
'hostname does not resolve, adoption is '
|
||||
'delegated to the computers collector '
|
||||
'(see DESIGN-DECISIONS-agent.md 1a). This '
|
||||
'plugin never creates assets itself.',
|
||||
},
|
||||
'loggedinuser': {'type': 'string'},
|
||||
'agentversion': {'type': 'string'},
|
||||
'lastboottime': {'type': 'string', 'format': 'date-time'},
|
||||
'uptimeseconds': {'type': 'integer'},
|
||||
'idleseconds': {'type': 'integer'},
|
||||
'cpupercent': {'type': 'number'},
|
||||
'memorypercent': {'type': 'number'},
|
||||
'services': {
|
||||
'type': 'array',
|
||||
'description': 'Monitored services/processes and their '
|
||||
'run state (UDC, eDnc, Intune, ...).',
|
||||
},
|
||||
'ports': {
|
||||
'type': 'array',
|
||||
'description': 'Open listening ports.',
|
||||
},
|
||||
'interfaces': {
|
||||
'type': 'array',
|
||||
'description': 'Network interface information.',
|
||||
},
|
||||
'scheduledtasks': {
|
||||
'type': 'array',
|
||||
'description': 'Scheduled task inventory + last run.',
|
||||
},
|
||||
'events': {
|
||||
'type': 'array',
|
||||
'description': 'Curated Windows Event Log records. Only '
|
||||
'IDs in the server catalog are stored.',
|
||||
'items': {
|
||||
'type': 'object',
|
||||
'required': ['eventid'],
|
||||
'properties': {
|
||||
'eventid': {'type': 'integer'},
|
||||
'eventchannel': {'type': 'string'},
|
||||
'eventsource': {'type': 'string'},
|
||||
'eventtime': {'type': 'string',
|
||||
'format': 'date-time'},
|
||||
'eventmessage': {'type': 'string'},
|
||||
'fields': {'type': 'object'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# PC-identifying and hardware fields forwarded to the computers collector,
|
||||
# which owns the Asset/Computer row and renders the Hardware and Network
|
||||
# cards. The endpoints plugin only caches telemetry, so these must be
|
||||
# relayed or vendor/model/OS/IP never land on the PC (see
|
||||
# DESIGN-DECISIONS-agent.md 1a).
|
||||
_COMPUTER_HANDOFF_FIELDS = (
|
||||
'loggedinuser', 'serialnumber', 'pctype', 'machinenumber',
|
||||
'vendorname', 'modelnumber', 'osname', 'ipaddress', 'lastboottime',
|
||||
)
|
||||
|
||||
def _forward_to_computers(self, hostname: str, payload: Dict):
|
||||
"""Return the computers plugin and the PC subset to hand off, or None.
|
||||
|
||||
Both adoption and steady-state reports relay the same hardware subset,
|
||||
so the Hardware and Network cards fill in on every report, not just at
|
||||
first contact.
|
||||
"""
|
||||
from flask import current_app
|
||||
|
||||
manager = current_app.extensions.get('plugin_manager')
|
||||
computers = manager.get_plugin('computers') if manager else None
|
||||
if computers is None:
|
||||
return None, None
|
||||
|
||||
subset = {'hostname': hostname}
|
||||
for field in self._COMPUTER_HANDOFF_FIELDS:
|
||||
if payload.get(field):
|
||||
subset[field] = payload.get(field)
|
||||
return computers, subset
|
||||
|
||||
def _adopt_computer(self, hostname: str, payload: Dict,
|
||||
warnings: List[str]) -> None:
|
||||
"""Delegate first-contact adoption to the computers collector.
|
||||
|
||||
This plugin never creates Asset or Computer rows itself (see
|
||||
DESIGN-DECISIONS-agent.md 1a). The computers plugin is the sole
|
||||
authority for that, so on an unknown hostname we forward the
|
||||
PC-identifying subset of the payload to its collector. It runs inside
|
||||
the same transaction, so a later failure rolls the adoption back.
|
||||
"""
|
||||
computers, adoptpayload = self._forward_to_computers(hostname, payload)
|
||||
if computers is None:
|
||||
raise ValueError(
|
||||
'cannot adopt {!r}: computers plugin is not available'
|
||||
.format(hostname))
|
||||
|
||||
computers.apply_collector_payload(adoptpayload)
|
||||
db.session.flush()
|
||||
warnings.append('adopted new computer for hostname: {!r}'.format(hostname))
|
||||
|
||||
def apply_collector_payload(self, payload: Dict) -> Dict:
|
||||
"""Idempotently upsert one endpoint's state and append its events.
|
||||
|
||||
The cached EndpointState and any EndpointEvent rows are written in the
|
||||
same transaction (the dispatcher owns the commit), so a reader never
|
||||
sees a snapshot without the events that produced it.
|
||||
"""
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
warnings: List[str] = []
|
||||
|
||||
hostname = (payload.get('hostname') or '').strip()
|
||||
if not hostname:
|
||||
raise ValueError('hostname is required')
|
||||
|
||||
computer = (Computer.query
|
||||
.filter(db.func.lower(Computer.hostname) == hostname.lower())
|
||||
.first())
|
||||
if computer is None:
|
||||
# First-contact adoption is delegated to the computers collector,
|
||||
# which is the only authority allowed to create Asset + Computer
|
||||
# rows (DESIGN-DECISIONS-agent.md 1a). This plugin never invents
|
||||
# assets itself. Adoption runs in the dispatcher's transaction, so a
|
||||
# later ValueError rolls the whole report back.
|
||||
if payload.get('adopt'):
|
||||
self._adopt_computer(hostname, payload, warnings)
|
||||
computer = (Computer.query
|
||||
.filter(db.func.lower(Computer.hostname)
|
||||
== hostname.lower())
|
||||
.first())
|
||||
if computer is None:
|
||||
raise ValueError(
|
||||
'could not resolve hostname to a computer: {!r}'.format(hostname))
|
||||
else:
|
||||
# Already adopted: relay the hardware/identity subset on every
|
||||
# report so the computers plugin keeps vendor/model/OS/IP current.
|
||||
# Without this, Hardware and Network stay blank after adoption
|
||||
# because this plugin only caches telemetry.
|
||||
computers, subset = self._forward_to_computers(hostname, payload)
|
||||
if computers is not None and len(subset) > 1:
|
||||
computers.apply_collector_payload(subset)
|
||||
db.session.flush()
|
||||
|
||||
assetid = computer.assetid
|
||||
|
||||
state = EndpointState.query.filter_by(assetid=assetid).first()
|
||||
action = 'updated'
|
||||
if state is None:
|
||||
state = EndpointState(assetid=assetid)
|
||||
db.session.add(state)
|
||||
action = 'created'
|
||||
|
||||
state.hostname = hostname
|
||||
if 'loggedinuser' in payload:
|
||||
state.loggedinuser = payload.get('loggedinuser')
|
||||
if 'agentversion' in payload:
|
||||
state.agentversion = payload.get('agentversion')
|
||||
if 'lastboottime' in payload:
|
||||
state.lastboottime = _parse_dt(payload.get('lastboottime'))
|
||||
if 'uptimeseconds' in payload:
|
||||
state.uptimeseconds = payload.get('uptimeseconds')
|
||||
if 'idleseconds' in payload:
|
||||
state.idleseconds = payload.get('idleseconds')
|
||||
if 'cpupercent' in payload:
|
||||
state.cpupercent = payload.get('cpupercent')
|
||||
if 'memorypercent' in payload:
|
||||
state.memorypercent = payload.get('memorypercent')
|
||||
if 'services' in payload:
|
||||
state.servicesjson = _as_json(payload.get('services'))
|
||||
if 'ports' in payload:
|
||||
state.portsjson = _as_json(payload.get('ports'))
|
||||
if 'interfaces' in payload:
|
||||
state.interfacesjson = _as_json(payload.get('interfaces'))
|
||||
if 'scheduledtasks' in payload:
|
||||
state.scheduledtasksjson = _as_json(payload.get('scheduledtasks'))
|
||||
|
||||
state.lastreportedat = datetime.utcnow()
|
||||
|
||||
stored_events = 0
|
||||
for record in (payload.get('events') or []):
|
||||
try:
|
||||
eventid = int(record.get('eventid'))
|
||||
except (TypeError, ValueError):
|
||||
warnings.append('skipped event with non-integer eventid')
|
||||
continue
|
||||
if eventid not in CURATED_EVENT_IDS:
|
||||
# Outside the catalog: ignored on purpose, not an error.
|
||||
continue
|
||||
db.session.add(EndpointEvent(
|
||||
assetid=assetid,
|
||||
eventid=eventid,
|
||||
eventchannel=record.get('eventchannel'),
|
||||
eventsource=record.get('eventsource'),
|
||||
eventtime=_parse_dt(record.get('eventtime')),
|
||||
eventmessage=record.get('eventmessage'),
|
||||
payloadjson=_as_json(record.get('fields')),
|
||||
))
|
||||
stored_events += 1
|
||||
|
||||
# flush, not commit: the collector dispatcher owns the transaction.
|
||||
db.session.flush()
|
||||
|
||||
return {
|
||||
'action': action,
|
||||
'assetid': assetid,
|
||||
'identityvalue': hostname,
|
||||
'storedevents': stored_events,
|
||||
'warnings': warnings,
|
||||
}
|
||||
171
plugins/endpoints/tests/test_plugin.py
Normal file
171
plugins/endpoints/tests/test_plugin.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Endpoints plugin smoke + collector-contract tests.
|
||||
|
||||
Asserts the plugin loads cleanly, satisfies the framework contract, and that
|
||||
its ADR-006 collector schema is well formed. Extend with DB-backed ingest tests
|
||||
as the plugin grows.
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
from plugins.endpoints.plugin import EndpointsPlugin, CURATED_EVENT_IDS
|
||||
from plugins.endpoints.eventcatalog import EVENT_CATALOG, CRITICAL_EVENT_IDS
|
||||
from plugins.endpoints.commands import (
|
||||
COMMAND_ALLOWLIST, is_allowed_action, elevated_permission_for,
|
||||
requires_target,
|
||||
)
|
||||
|
||||
|
||||
def test_endpoints_plugin_meta_is_valid():
|
||||
"""meta returns a PluginMeta with the expected name and prefix."""
|
||||
plugin = EndpointsPlugin()
|
||||
assert plugin.meta.name == 'endpoints'
|
||||
assert plugin.meta.api_prefix == '/api/endpoints'
|
||||
assert 'computers' in plugin.meta.dependencies
|
||||
|
||||
|
||||
def test_endpoints_plugin_get_blueprint_returns_blueprint():
|
||||
"""get_blueprint returns a Flask Blueprint, not None."""
|
||||
plugin = EndpointsPlugin()
|
||||
assert isinstance(plugin.get_blueprint(), Blueprint)
|
||||
|
||||
|
||||
def test_endpoints_plugin_get_models_returns_models():
|
||||
"""get_models returns the two telemetry tables."""
|
||||
plugin = EndpointsPlugin()
|
||||
models = plugin.get_models()
|
||||
assert len(models) >= 2
|
||||
for model in models:
|
||||
assert hasattr(model, '__tablename__')
|
||||
|
||||
|
||||
def test_endpoints_collector_schema_identity_is_hostname():
|
||||
"""ADR-006 schema declares hostname as the identity field."""
|
||||
plugin = EndpointsPlugin()
|
||||
schema = plugin.get_collector_schema()
|
||||
assert schema is not None
|
||||
assert schema['identityfield'] == 'hostname'
|
||||
assert 'hostname' in schema['fields']['required']
|
||||
|
||||
|
||||
def test_endpoints_curated_event_ids_include_core_security_ids():
|
||||
"""The curated catalog covers the agreed logon/stability IDs."""
|
||||
for eventid in (4624, 4625, 4634, 4672, 4697, 4698, 41, 4618, 55, 7011,
|
||||
10016, 1001):
|
||||
assert eventid in CURATED_EVENT_IDS
|
||||
|
||||
|
||||
def test_curated_ids_match_the_event_catalog():
|
||||
"""Ingest filter and display catalog cannot drift apart."""
|
||||
assert CURATED_EVENT_IDS == frozenset(EVENT_CATALOG)
|
||||
|
||||
|
||||
def test_critical_event_ids_are_a_subset_of_the_catalog():
|
||||
"""Dashboard stability card only queries known critical IDs."""
|
||||
assert set(CRITICAL_EVENT_IDS)
|
||||
assert set(CRITICAL_EVENT_IDS).issubset(set(EVENT_CATALOG))
|
||||
|
||||
|
||||
def test_endpoints_asset_panels_are_computer_scoped():
|
||||
"""Every asset panel targets computer assets only, with an endpoint."""
|
||||
plugin = EndpointsPlugin()
|
||||
panels = plugin.get_asset_panels()
|
||||
assert len(panels) == 5
|
||||
for panel in panels:
|
||||
assert panel['assettypes'] == ['computer']
|
||||
assert panel['endpoint'].startswith('/api/endpoints/asset/{assetid}')
|
||||
assert panel['render'] in ('keyvalue', 'list', 'table')
|
||||
|
||||
|
||||
def test_services_and_processes_are_split_into_two_filtered_cards():
|
||||
"""The single services card is now two cards filtered by row kind, both
|
||||
reading the same services snapshot so a future top-20 collector is just
|
||||
another filtered panel over the same feed."""
|
||||
plugin = EndpointsPlugin()
|
||||
panels = {p['id']: p for p in plugin.get_asset_panels()}
|
||||
services = panels['endpoints-tracked-services']
|
||||
processes = panels['endpoints-tracked-processes']
|
||||
assert services['title'] == 'Tracked Services'
|
||||
assert processes['title'] == 'Tracked Processes'
|
||||
assert services['filter'] == {'key': 'kind', 'equals': 'service'}
|
||||
assert processes['filter'] == {'key': 'kind', 'equals': 'process'}
|
||||
# Both slice one endpoint feed.
|
||||
assert services['endpoint'] == processes['endpoint']
|
||||
assert services['endpoint'].endswith('/services')
|
||||
# Command wiring carries over to both cards.
|
||||
assert services['permission'] == 'endpoints.command'
|
||||
assert processes['permission'] == 'endpoints.command'
|
||||
|
||||
|
||||
def test_recent_events_card_is_last_and_starts_collapsed():
|
||||
"""Events is the lowest-priority card: forced to the bottom of the column
|
||||
and collapsed by default."""
|
||||
plugin = EndpointsPlugin()
|
||||
panels = plugin.get_asset_panels()
|
||||
events = next(p for p in panels if p['id'] == 'endpoints-events')
|
||||
assert events['collapsedDefault'] is True
|
||||
assert events['position'] == max(p['position'] for p in panels)
|
||||
|
||||
|
||||
def test_endpoints_dashboard_widgets_declared():
|
||||
"""Quiet + stability exception cards are declared and hide when empty."""
|
||||
plugin = EndpointsPlugin()
|
||||
widgets = plugin.get_dashboard_widgets()
|
||||
ids = {w['id'] for w in widgets}
|
||||
assert {'endpoints-quiet', 'endpoints-stability'} <= ids
|
||||
for widget in widgets:
|
||||
assert widget['render'] == 'exceptions'
|
||||
assert widget['empty'] == 'hide'
|
||||
assert widget['permission'] == 'endpoints.view'
|
||||
|
||||
|
||||
def test_endpoints_navigation_item_points_at_fleet_view():
|
||||
"""The fleet list view is exposed as a single /endpoints nav item."""
|
||||
plugin = EndpointsPlugin()
|
||||
items = plugin.get_navigation_items()
|
||||
routes = {item['route'] for item in items}
|
||||
assert '/endpoints' in routes
|
||||
|
||||
|
||||
def test_system_restart_is_allowlisted_and_policy_selectable():
|
||||
"""Restart is a real allowlisted action, so the policy builder (which
|
||||
reads COMMAND_ALLOWLIST) surfaces it as a selectable action."""
|
||||
assert is_allowed_action('system_restart')
|
||||
assert 'system_restart' in COMMAND_ALLOWLIST
|
||||
assert COMMAND_ALLOWLIST['system_restart']['label'] == 'Restart computer'
|
||||
|
||||
|
||||
def test_system_restart_requires_the_elevated_restart_permission():
|
||||
"""Restart maps to endpoints.restart so the enqueue route can demand a
|
||||
heavier grant than the routine endpoints.command toggle. Routine actions
|
||||
carry no elevated requirement."""
|
||||
assert elevated_permission_for('system_restart') == 'endpoints.restart'
|
||||
assert elevated_permission_for('mtconnect_start') is None
|
||||
|
||||
|
||||
def test_endpoints_declares_the_restart_permission():
|
||||
"""The dedicated restart permission is declared for role-grid seeding."""
|
||||
plugin = EndpointsPlugin()
|
||||
permissions = {name for name, *_ in plugin.get_permissions()}
|
||||
assert 'endpoints.restart' in permissions
|
||||
assert 'endpoints.command' in permissions
|
||||
|
||||
|
||||
def test_collect_now_is_allowlisted_targetless_and_routine():
|
||||
"""collect_now forces an off-cadence telemetry report. It is allowlisted so
|
||||
the enqueue route accepts it, carries no wire target (fixed action), and
|
||||
needs no elevated grant beyond endpoints.command."""
|
||||
assert is_allowed_action('collect_now')
|
||||
assert 'collect_now' in COMMAND_ALLOWLIST
|
||||
assert not requires_target('collect_now')
|
||||
assert elevated_permission_for('collect_now') is None
|
||||
|
||||
|
||||
def test_telemetrymeta_route_is_registered_for_freshness_gating():
|
||||
"""The WebUI gates panel refreshes on lastreportedat advancing, so the
|
||||
lightweight freshness probe must be exposed on the plugin blueprint."""
|
||||
from flask import Flask
|
||||
|
||||
application = Flask(__name__)
|
||||
application.register_blueprint(EndpointsPlugin().get_blueprint())
|
||||
rules = {rule.rule for rule in application.url_map.iter_rules()}
|
||||
assert any(rule.endswith('/asset/<int:asset_id>/telemetrymeta') for rule in rules)
|
||||
@@ -73,7 +73,7 @@ def _trusted_client_ip():
|
||||
- A client reaching waitress directly is not a trusted proxy, so there
|
||||
remote_addr is its own peer address.
|
||||
|
||||
The rule is the control. See docs/geenforce-api-cutover.md; the Windows
|
||||
The rule is the control. See the geenforce-api-cutover wiki page; the Windows
|
||||
installer enables it and verifies it is live at the end of an install.
|
||||
"""
|
||||
return request.remote_addr or ''
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<code>common</code> manifest uses PC types to hit a subset; a per-type
|
||||
manifest already only runs on its own type, so it shows just the niche
|
||||
gate it needs (CMM version, a bay number, a subtype).</p>
|
||||
<p>Full guide: <code>docs/GE-ENFORCE.md</code>.</p>
|
||||
<p>Full guide: <code>the GE-ENFORCE wiki page</code>.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ ordered list of `ManifestEntry` rows - one per Applications[] entry in the
|
||||
on-share manifest.json - plus child rows for the multi-value targeting filters
|
||||
and the nested InUseCheck.
|
||||
|
||||
Design choices (see docs/proposals/ge-enforce-plugin.md):
|
||||
Design choices (see the ge-enforce-plugin proposal in the project wiki):
|
||||
- ONE wide entries table with an `entrytype` discriminator and nullable
|
||||
per-type columns. Not STI, not a JSON blob: the whole fleet is ~64 entries,
|
||||
so sparse columns are free and every field stays queryable + plain-SQL
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""GE-Enforce manifest-store plugin.
|
||||
|
||||
Owns the imaging-PC-type scopes and their install manifests as shopdb data
|
||||
(see docs/proposals/ge-enforce-plugin.md). This is the P0/P1 foundation: models,
|
||||
(see the ge-enforce-plugin proposal in the project wiki). This is the P0/P1 foundation: models,
|
||||
the alias-graph seed, and the client-facing manifest endpoint. Authoring UI and
|
||||
client cutover come in later phases.
|
||||
"""
|
||||
|
||||
@@ -326,7 +326,7 @@ def read_revision(revisionid):
|
||||
Gated on its PARENT, not on guides.view alone. Revision ids are sequential
|
||||
integers, so an ungated route here is an invitation to walk 1..n and read
|
||||
every unpublished draft on the site - which is the opposite of what
|
||||
docs/GUIDES.md promises about drafts.
|
||||
the GUIDES wiki page promises about drafts.
|
||||
|
||||
A reader may fetch the published revision (it is what the slug route would
|
||||
serve anyway); everything else takes edit rights on that guide.
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
>
|
||||
<option value="">Select vendor...</option>
|
||||
<option
|
||||
v-for="v in vendors"
|
||||
v-for="v in filteredVendors"
|
||||
:key="v.vendorid"
|
||||
:value="v.vendorid"
|
||||
>
|
||||
@@ -168,7 +168,7 @@
|
||||
>
|
||||
<option value="">Select vendor...</option>
|
||||
<option
|
||||
v-for="v in vendors"
|
||||
v-for="v in filteredControllerVendors"
|
||||
:key="v.vendorid"
|
||||
:value="v.vendorid"
|
||||
>
|
||||
@@ -446,16 +446,67 @@ const existingRelationshipId = ref(null)
|
||||
const loadedControllingPcId = ref(null)
|
||||
const currentMachine = ref(null)
|
||||
|
||||
// Filter models by selected machine vendor
|
||||
// Ensure a saved model id is present in models.value even when the filtered
|
||||
// list (category Equipment) excluded it because it has no modeltype. Fetches
|
||||
// the single row by id and appends it; a no-op when already present or blank.
|
||||
async function ensureModelLoaded(modelId) {
|
||||
if (!modelId) return
|
||||
if (models.value.some(m => m.modelnumberid === modelId)) return
|
||||
try {
|
||||
const response = await modelsApi.get(modelId)
|
||||
const model = response.data.data
|
||||
if (model) models.value = [...models.value, model]
|
||||
} catch (e) {
|
||||
// Leave it absent; the select shows blank but we did not invent a value.
|
||||
}
|
||||
}
|
||||
|
||||
// Filter models by selected machine vendor.
|
||||
// only in the controller dropdown, so keep them out of the machine list. Keep
|
||||
// the record's own saved model even if it is untyped or a different vendor, so
|
||||
// editing a machine never renders a blank select that clears the field on save.
|
||||
const filteredModels = computed(() => {
|
||||
if (!form.value.vendorid) return models.value
|
||||
return models.value.filter(m => m.vendorid === form.value.vendorid)
|
||||
const savedId = form.value.modelnumberid
|
||||
const machineModels = models.value.filter(
|
||||
m => m.modeltype !== 'Controller' || m.modelnumberid === savedId)
|
||||
if (!form.value.vendorid) return machineModels
|
||||
return machineModels.filter(
|
||||
m => m.vendorid === form.value.vendorid || m.modelnumberid === savedId)
|
||||
})
|
||||
|
||||
// Filter models by selected controller vendor
|
||||
// Filter models by selected controller vendor. Only Controller-type models are
|
||||
// valid here. Keep the record's own saved controller model even if it is untyped
|
||||
// or a different vendor, for the same reason as filteredModels above.
|
||||
const filteredControllerModels = computed(() => {
|
||||
if (!form.value.controllervendorid) return models.value
|
||||
return models.value.filter(m => m.vendorid === form.value.controllervendorid)
|
||||
const savedId = form.value.controllermodelid
|
||||
const controllerModels = models.value.filter(
|
||||
m => m.modeltype === 'Controller' || m.modelnumberid === savedId)
|
||||
if (!form.value.controllervendorid) return controllerModels
|
||||
return controllerModels.filter(
|
||||
m => m.vendorid === form.value.controllervendorid || m.modelnumberid === savedId)
|
||||
})
|
||||
|
||||
// Only offer vendors that have machine (non-controller Equipment) models. Keep
|
||||
// the record's own vendor even if it falls outside that set, so editing an
|
||||
// existing machine never silently drops its saved vendor.
|
||||
const filteredVendors = computed(() => {
|
||||
const machineVendorIds = new Set(
|
||||
models.value.filter(m => m.modeltype !== 'Controller').map(m => m.vendorid)
|
||||
)
|
||||
return vendors.value.filter(
|
||||
v => machineVendorIds.has(v.vendorid) || v.vendorid === form.value.vendorid
|
||||
)
|
||||
})
|
||||
|
||||
// Only offer controller vendors that have Controller-type models. Keep the
|
||||
// record's own controller vendor even if it falls outside that set.
|
||||
const filteredControllerVendors = computed(() => {
|
||||
const controllerVendorIds = new Set(
|
||||
models.value.filter(m => m.modeltype === 'Controller').map(m => m.vendorid)
|
||||
)
|
||||
return vendors.value.filter(
|
||||
v => controllerVendorIds.has(v.vendorid) || v.vendorid === form.value.controllervendorid
|
||||
)
|
||||
})
|
||||
|
||||
// Clear model selection when vendor changes
|
||||
@@ -485,8 +536,8 @@ onMounted(async () => {
|
||||
machinesApi.types.list(),
|
||||
assetsApi.statuses.list(),
|
||||
vendorsApi.list({ perpage: 500 }),
|
||||
locationsApi.list({ perpage: 500 }),
|
||||
modelsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
locationsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
modelsApi.listAll({ category: 'Equipment' }), // backend caps perpage at 100; page through all
|
||||
businessunitsApi.list({ perpage: 500 }),
|
||||
computersApi.listAll(), // backend caps perpage at 100; page through all
|
||||
assetsApi.types.list() // Used for relationship types, will fix below
|
||||
@@ -495,7 +546,7 @@ onMounted(async () => {
|
||||
machineTypes.value = typesRes.data.data || []
|
||||
statuses.value = statusRes.data.data || []
|
||||
vendors.value = vendorRes.data.data || []
|
||||
locations.value = locRes.data.data || []
|
||||
locations.value = locRes || []
|
||||
models.value = allModels
|
||||
businessunits.value = buRes.data.data || []
|
||||
// listAll resolves to the ARRAY, not a response: fetchAllPages already
|
||||
@@ -568,6 +619,14 @@ onMounted(async () => {
|
||||
levelid: data.levelid ?? null,
|
||||
}
|
||||
|
||||
// The model list is loaded filtered to category Equipment, which the
|
||||
// backend inner-joins against ModelType - so a model with no modeltype is
|
||||
// absent from models.value. If this machine's saved model or controller
|
||||
// model is one of those untyped rows, fetch it by id and merge it in, or
|
||||
// its dropdown would render blank and saving would clear the field.
|
||||
await ensureModelLoaded(form.value.modelnumberid)
|
||||
await ensureModelLoaded(form.value.controllermodelid)
|
||||
|
||||
// Load existing relationships to find controlling PC
|
||||
if (data.assetid) {
|
||||
try {
|
||||
|
||||
@@ -181,13 +181,13 @@ onMounted(async () => {
|
||||
const [typesResponse, statusResponse, locResponse, buResponse, assetTypesResponse] = await Promise.all([
|
||||
measuringtoolsApi.types.list({ perpage: 200 }),
|
||||
assetsApi.statuses.list(),
|
||||
locationsApi.list({ perpage: 500 }),
|
||||
locationsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
businessunitsApi.list({ perpage: 500 }),
|
||||
assetsApi.types.list(),
|
||||
])
|
||||
types.value = typesResponse.data.data || []
|
||||
statuses.value = statusResponse.data.data || []
|
||||
locations.value = locResponse.data.data || []
|
||||
locations.value = locResponse || []
|
||||
businessunits.value = buResponse.data.data || []
|
||||
|
||||
const assetType = (assetTypesResponse.data.data || []).find(a => a.assettype === 'measuring_tool')
|
||||
|
||||
@@ -460,8 +460,8 @@ async function loadVendors() {
|
||||
|
||||
async function loadLocations() {
|
||||
try {
|
||||
const response = await locationsApi.list({ perpage: 100 })
|
||||
locations.value = response.data.data || []
|
||||
const items = await locationsApi.listAll() // backend caps perpage at 100; page through all
|
||||
locations.value = items || []
|
||||
} catch (err) {
|
||||
console.error('Error loading locations:', err)
|
||||
}
|
||||
|
||||
@@ -197,8 +197,8 @@ async function loadVendors() {
|
||||
|
||||
async function loadLocations() {
|
||||
try {
|
||||
const response = await locationsApi.list({ perpage: 100 })
|
||||
locations.value = response.data.data || []
|
||||
const items = await locationsApi.listAll() // backend caps perpage at 100; page through all
|
||||
locations.value = items || []
|
||||
} catch (error) {
|
||||
console.error('Error loading locations:', error)
|
||||
}
|
||||
|
||||
@@ -352,8 +352,8 @@ async function loadVLANs() {
|
||||
|
||||
async function loadLocations() {
|
||||
try {
|
||||
const response = await locationsApi.list({ perpage: 100 })
|
||||
locations.value = response.data.data || []
|
||||
const items = await locationsApi.listAll() // backend caps perpage at 100; page through all
|
||||
locations.value = items || []
|
||||
} catch (err) {
|
||||
console.error('Error loading locations:', err)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ This plugin was generated by `flask plugin new printedparts`. It satisfies the f
|
||||
|
||||
| You want to... | Do this |
|
||||
|---|---|
|
||||
| Add a hook (search, navigation, dashboard widget) | Override the method in `PrintedpartsPlugin`. See `docs/PLUGIN-HOOKS.md`. |
|
||||
| Add a hook (search, navigation, dashboard widget) | Override the method in `PrintedpartsPlugin`. See the PLUGIN-HOOKS wiki page. |
|
||||
| Accept external collector data | Override `get_collector_schema()` to return a JSON Schema. See ADR-006. |
|
||||
| Add another model | Create `models/<other>.py`, export it in `models/__init__.py`, return it in `get_models()`. |
|
||||
| Add a CLI command | Override `get_cli_commands()` returning a list of Click commands. |
|
||||
@@ -37,8 +37,8 @@ pytest plugins/printedparts/tests/
|
||||
|
||||
## References
|
||||
|
||||
- `docs/PLUGIN-HOOKS.md` - canonical hook reference
|
||||
- `docs/PLUGIN-QUICKSTART.md` - 30-minute walkthrough
|
||||
- the PLUGIN-HOOKS wiki page - canonical hook reference
|
||||
- the PLUGIN-QUICKSTART wiki page - 30-minute walkthrough
|
||||
- `migrations/adr/ADR-001-asset-as-platform-contract.md` - the platform contract
|
||||
- `migrations/adr/ADR-002-plugin-versioning.md` - versioning rules
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
Add schema classes here when you need request/response validation
|
||||
beyond the simple to_dict() output. The framework wires marshmallow
|
||||
into the response helpers; see docs/PLUGIN-HOOKS.md for details.
|
||||
into the response helpers; see the PLUGIN-HOOKS wiki page for details.
|
||||
"""
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
>
|
||||
<option value="">Select vendor...</option>
|
||||
<option
|
||||
v-for="v in vendors"
|
||||
v-for="v in filteredVendors"
|
||||
:key="v.vendorid"
|
||||
:value="v.vendorid"
|
||||
>
|
||||
@@ -370,9 +370,28 @@ const vendors = ref([])
|
||||
const models = ref([])
|
||||
const locations = ref([])
|
||||
|
||||
// Ensure a saved model id is present in models.value even when the filtered
|
||||
// list (category Printer) excluded it because it has no modeltype. Fetches the
|
||||
// single row by id and appends it; a no-op when already present or blank.
|
||||
async function ensureModelLoaded(modelId) {
|
||||
if (!modelId) return
|
||||
if (models.value.some(m => m.modelnumberid === modelId)) return
|
||||
try {
|
||||
const response = await modelsApi.get(modelId)
|
||||
const model = response.data.data
|
||||
if (model) models.value = [...models.value, model]
|
||||
} catch (e) {
|
||||
// Leave it absent; the select shows blank but we did not invent a value.
|
||||
}
|
||||
}
|
||||
|
||||
// Filter models by selected vendor and printer type
|
||||
const filteredModels = computed(() => {
|
||||
const savedId = form.value.modelnumberid
|
||||
return models.value.filter(m => {
|
||||
// Always keep the record's own saved model, so editing a printer never
|
||||
// renders a blank select that clears the field on save.
|
||||
if (m.modelnumberid === savedId) return true
|
||||
// Filter by vendor if selected
|
||||
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
|
||||
return false
|
||||
@@ -387,6 +406,17 @@ const filteredModels = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// Only offer vendors that actually have Printer-category models. models is
|
||||
// already scoped to category Printer at load, so its vendorids are the printer
|
||||
// vendor set. Keep the record's own vendor even if it falls outside that set,
|
||||
// so editing an existing printer never silently drops its saved vendor.
|
||||
const filteredVendors = computed(() => {
|
||||
const printerVendorIds = new Set(models.value.map(m => m.vendorid))
|
||||
return vendors.value.filter(
|
||||
v => printerVendorIds.has(v.vendorid) || v.vendorid === form.value.vendorid
|
||||
)
|
||||
})
|
||||
|
||||
// Get short description from model number for naming
|
||||
function getModelShortDesc(modelNumber) {
|
||||
if (!modelNumber) return ''
|
||||
@@ -493,15 +523,15 @@ onMounted(async () => {
|
||||
printersApi.types.list({ perpage: 100 }),
|
||||
assetsApi.statuses.list(),
|
||||
vendorsApi.list({ perpage: 100 }),
|
||||
modelsApi.listAll(),
|
||||
locationsApi.list({ perpage: 100 })
|
||||
modelsApi.listAll({ category: 'Printer' }),
|
||||
locationsApi.listAll() // backend caps perpage at 100; page through all
|
||||
])
|
||||
|
||||
printerTypes.value = mtRes.data.data || []
|
||||
statuses.value = statusRes.data.data || []
|
||||
vendors.value = vendorRes.data.data || []
|
||||
models.value = allModels
|
||||
locations.value = locRes.data.data || []
|
||||
locations.value = locRes || []
|
||||
|
||||
// Load printer if editing
|
||||
if (isEdit.value) {
|
||||
@@ -539,6 +569,12 @@ onMounted(async () => {
|
||||
pin: ext.pin || ''
|
||||
}
|
||||
|
||||
// The model list is loaded filtered to category Printer, which the backend
|
||||
// inner-joins against ModelType - so an untyped model is absent. If this
|
||||
// printer's saved model is one of those rows, fetch it by id and merge it
|
||||
// in, or its dropdown would render blank and saving would clear the field.
|
||||
await ensureModelLoaded(form.value.modelnumberid)
|
||||
|
||||
// Don't auto-generate for existing printers
|
||||
manualWindowsName.value = true
|
||||
manualHostname.value = true
|
||||
|
||||
Reference in New Issue
Block a user