Merge wilm: endpoints plugin, agent, policies, CSV import, Entra fields
Thirteen commits from benjamin.baker plus the review fixes. Everything he built is here: the endpoints plugin and its command queue, the .NET endpoint agent, the computers policies subsystem, the site-asset CSV import with its adapters and collector, Entra device identity, and the catalog hand-merge. His branch predates four commits on main (section ticking, the generator share links, Secure Send copy message, and the smoke-test workflow), so those come through untouched - nothing of ours is removed by this merge. wilm stays as its own branch; this does not close it.
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')
|
||||
154
plugins/computers/migrations/versions/0004_policies.py
Normal file
154
plugins/computers/migrations/versions/0004_policies.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""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
|
||||
|
||||
|
||||
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.JSON(), 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.JSON(), nullable=True),
|
||||
sa.Column('params', sa.JSON(), 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.
|
||||
|
||||
|
||||
197
plugins/computers/models/policy.py
Normal file
197
plugins/computers/models/policy.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""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
|
||||
|
||||
|
||||
# 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(db.JSON, 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(db.JSON, 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(db.JSON, 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)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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