From 414a7243d7a4cf0a20b43d87ee27f009e86df711 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 28 Aug 2026 22:34:59 -0400 Subject: [PATCH] Merge events and commands into one Activity feed, and lead with live state Two changes to the PC page, both about what a responder meets first. LIVE STATE FIRST. The contributed panels moved to the head of the card flow. The grid is a two-column FLOW (column-count, with display:contents on the column wrappers), so a card cannot be pinned to a side and only document order decides where it lands - putting the panels first fills the left column with endpoint status, services, processes, interfaces and activity, and leaves identity, hardware, network and location to the right. Panels still self-order among themselves by declared position and still self-hide when no plugin answers for the asset type. ONE ACTIVITY FEED replaces the Recent events card. Events and commands answer the same question - what happened to this box - and reading them as two lists meant interleaving by eye. GET /api/endpoints/asset//activity merges them newest-first, capped at 15. Both are keyed by assetid, so it is two indexed reads and a merge sort rather than a join, and it renders through the existing ADR-010 list renderer with no bespoke frontend work. A failed command is marked critical, because that is the row somebody needs to notice. Not collapsed by default, unlike the card it replaces. The core AuditLog is deliberately NOT a third source: its rows carry the action as the entity name and the asset only inside a details string, so including it would mean parsing prose to find out which machine a row belongs to. Requester names are withheld from callers who cannot themselves issue commands, via caller_holds. A read-only viewer - a guest, on a site that grants this panel - gets the history without a roster of who restarted what, and the lookup is skipped entirely rather than fetched and discarded. requestedby holds a user ID, so names resolve through one batched query, not one per row. Verified on dev telemetry: a queued command shows as 'Collect now / Pending by admin' above the Windows events, ordered by time. Full suite 2124 passed, vitest 260 passed, naming/contract/parity gates green, spec regenerated at 332 paths. --- docs/PROJECT-MAP.md | 2 +- docs/api-inventory.json | 8 ++ docs/openapi.json | 51 ++++++++++++ plugins/computers/frontend/views/PCDetail.vue | 13 ++- plugins/endpoints/api/routes.py | 80 ++++++++++++++++++- plugins/endpoints/plugin.py | 18 +++-- 6 files changed, 161 insertions(+), 11 deletions(-) diff --git a/docs/PROJECT-MAP.md b/docs/PROJECT-MAP.md index 17687f93..3cf2ef68 100644 --- a/docs/PROJECT-MAP.md +++ b/docs/PROJECT-MAP.md @@ -91,5 +91,5 @@ Manifest-less directories under `plugins/` are core frontend surface and always ## Size - test functions defined: **1449** (parametrised cases collect higher) -- documented API paths: **331** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`) +- documented API paths: **332** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`) diff --git a/docs/api-inventory.json b/docs/api-inventory.json index e162133c..2254f0c4 100644 --- a/docs/api-inventory.json +++ b/docs/api-inventory.json @@ -3811,6 +3811,14 @@ "purpose": "Asset-detail panel: the most recent agent events for one endpoint", "example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1asset_id/recentevents" }, + { + "method": "GET", + "path": "/api/endpoints/asset//activity", + "auth": "endpoints.view", + "params": "path: asset_id", + "purpose": "Asset-detail panel: one chronological feed merging curated Windows events with operator commands, newest first, capped at 15. Both are keyed by assetid, so this is two indexed reads and a merge rather than a join. A failed command is marked critical. The requester name is withheld from callers who do not themselves hold endpoints.command, so a read-only viewer gets the history without the roster of who restarted what", + "example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/activity" + }, { "method": "GET", "path": "/api/endpoints/asset//interfaces", diff --git a/docs/openapi.json b/docs/openapi.json index 42be3046..b43fc9e2 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -22077,6 +22077,57 @@ ] } }, + "/api/endpoints/asset/{asset_id}/activity": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Asset-detail panel: one chronological feed merging curated Windows events with operator commands, newest first, capped...", + "description": "Asset-detail panel: one chronological feed merging curated Windows events with operator commands, newest first, capped at 15. Both are keyed by assetid, so this is two indexed reads and a merge rather than a join. A failed command is marked critical. The requester name is withheld from callers who do not themselves hold endpoints.command, so a read-only viewer gets the history without the roster of who restarted what\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/activity\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, "/api/endpoints/asset/{asset_id}/interfaces": { "get": { "tags": [ diff --git a/plugins/computers/frontend/views/PCDetail.vue b/plugins/computers/frontend/views/PCDetail.vue index 198c1382..45cd3647 100644 --- a/plugins/computers/frontend/views/PCDetail.vue +++ b/plugins/computers/frontend/views/PCDetail.vue @@ -64,7 +64,17 @@ +
+ +
@@ -258,9 +268,6 @@ - - - diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index 7f138402..8c388e50 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -18,7 +18,7 @@ from shopdb.api import ( ErrorCodes, get_pagination_params, paginate_query, require_permission, require_role, caller_holds, mint_service_token, list_service_tokens, revoke_service_tokens, - AuditLog, collector_authorized, + AuditLog, collector_authorized, User, ) from ..models import EndpointState, EndpointEvent, EndpointCommand @@ -490,6 +490,84 @@ def panel_recent_events(asset_id: int): return success_response(rows) +@endpoints_bp.route('/asset//activity', methods=['GET']) +@require_permission('endpoints.view') +def panel_activity(asset_id: int): + """List panel: one chronological feed of what happened to this endpoint. + + Curated Windows events and operator commands merged and sorted newest + first. They answer the same question - what happened to this box - and + reading them as two separate cards means interleaving them by eye. + + Both sources are already keyed by assetid, so this is two indexed reads and + a merge sort, not a join. The core AuditLog is deliberately NOT a third + source: its rows carry the action as the entity name and the asset only + inside a details string, so pulling it in would mean parsing prose. + + The requester's name is withheld from callers who cannot themselves issue + commands. A read-only viewer - a guest, on a site that grants this panel - + has no need for the roster of who restarted what, and the feed is just as + useful without it. + """ + naming = caller_holds('endpoints.command') + + events = (EndpointEvent.query + .filter_by(assetid=asset_id) + .order_by(EndpointEvent.eventtime.desc()) + .limit(15) + .all()) + + commands = (EndpointCommand.query + .filter_by(assetid=asset_id) + .order_by(EndpointCommand.createdat.desc()) + .limit(15) + .all()) + + # requestedby holds a user ID, not a name. One batched lookup rather than a + # query per row, and only when the caller is allowed to see the names at all. + names = {} + if naming: + userids = {c.requestedby for c in commands if c.requestedby} + if userids: + names = {u.userid: (u.username or '') + for u in User.query.filter(User.userid.in_(userids)).all()} + + rows = [] + for event in events: + rows.append({ + 'title': event_title(event.eventid), + 'severity': event_severity(event.eventid), + 'detail': 'Windows event {}'.format(event.eventid), + 'eventtime': event.eventtime, + }) + + for command in commands: + label = COMMAND_ALLOWLIST.get(command.action, {}).get('label') or command.action + target = ' on {}'.format(command.target) if command.target else '' + who = '' + if naming and command.requestedby: + username = names.get(command.requestedby) + if username: + who = ' by {}'.format(username) + rows.append({ + 'title': '{}{}'.format(label, target), + # A failed command is the one an operator needs to notice. + 'severity': 'critical' if command.status == EndpointCommand.STATUS_FAILED else 'info', + 'detail': '{}{}'.format((command.status or 'queued').title(), who), + 'eventtime': command.createdat, + }) + + # Undated rows sort last rather than crashing the comparison. + rows.sort(key=lambda r: (r['eventtime'] is not None, r['eventtime']), reverse=True) + + return success_response([{ + 'title': r['title'], + 'severity': r['severity'], + 'detail': r['detail'], + 'eventtime': r['eventtime'].isoformat() + 'Z' if r['eventtime'] else None, + } for r in rows[:15]]) + + @endpoints_bp.route('/asset//interfaces', methods=['GET']) @require_permission('endpoints.view') def panel_interfaces(asset_id: int): diff --git a/plugins/endpoints/plugin.py b/plugins/endpoints/plugin.py index 348c157b..944d3a05 100644 --- a/plugins/endpoints/plugin.py +++ b/plugins/endpoints/plugin.py @@ -289,10 +289,16 @@ class EndpointsPlugin(BasePlugin): 'position': 13, }, { - 'id': 'endpoints-events', - 'title': 'Recent events', + # One feed instead of two cards. Events and commands answer the + # same question - what happened to this box - and reading them + # separately meant interleaving two lists by eye. Sits high on + # the page because it is the first thing looked at when + # something has gone wrong, and NOT collapsed by default for + # the same reason. + 'id': 'endpoints-activity', + 'title': 'Activity', 'assettypes': ['computer'], - 'endpoint': '/api/endpoints/asset/{assetid}/recentevents', + 'endpoint': '/api/endpoints/asset/{assetid}/activity', 'render': 'list', 'collapsible': True, 'status': { @@ -314,12 +320,12 @@ class EndpointsPlugin(BasePlugin): }, }, 'meta': [ + {'key': 'detail'}, {'key': 'eventtime', 'label': 'At', 'format': 'date'}, ], }, - 'empty': 'No recent events.', - 'collapsedDefault': True, - 'position': 90, + 'empty': 'Nothing recorded for this endpoint yet.', + 'position': 20, }, { 'id': 'endpoints-interfaces',