harden CSV import reconciliation and preserve saved model selections

Default fuzzy reconcile threshold to 0 and refuse merges when digit runs differ so numbered labels like Bay 311 never bind to Bay 310. Keep edit forms from clearing saved model fields when category filtering hides untyped rows. Includes in-progress computers policy and endpoints command feature work.
This commit is contained in:
benjamin.baker
2026-08-28 09:29:09 -04:00
parent d68c6db9ab
commit 01734e5449
46 changed files with 4241 additions and 52 deletions

View File

@@ -26,7 +26,7 @@ from shopdb.api import db
from .api import endpoints_bp
from .eventcatalog import EVENT_CATALOG
from .models import EndpointState, EndpointEvent
from .models import EndpointState, EndpointEvent, EndpointCommand
logger = logging.getLogger(__name__)
@@ -91,7 +91,7 @@ class EndpointsPlugin(BasePlugin):
return endpoints_bp
def get_models(self) -> List[Type]:
return [EndpointState, EndpointEvent]
return [EndpointState, EndpointEvent, EndpointCommand]
def get_permissions(self) -> List:
"""RBAC for the human-facing read routes.
@@ -103,6 +103,70 @@ class EndpointsPlugin(BasePlugin):
"""
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'),
# 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]:
@@ -150,10 +214,22 @@ class EndpointsPlugin(BasePlugin):
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/services',
'render': 'list',
'collapsible': True,
'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,
},
@@ -163,6 +239,15 @@ class EndpointsPlugin(BasePlugin):
'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': {
@@ -180,6 +265,25 @@ class EndpointsPlugin(BasePlugin):
'empty': 'No recent events.',
'position': 14,
},
{
'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]:
@@ -240,6 +344,14 @@ class EndpointsPlugin(BasePlugin):
'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'},
@@ -286,6 +398,56 @@ class EndpointsPlugin(BasePlugin):
},
}
# 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.
@@ -305,8 +467,29 @@ class EndpointsPlugin(BasePlugin):
.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))
# 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
@@ -373,4 +556,4 @@ class EndpointsPlugin(BasePlugin):
'identityvalue': hostname,
'storedevents': stored_events,
'warnings': warnings,
}
}