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/<id>/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.
This commit is contained in:
@@ -91,5 +91,5 @@ Manifest-less directories under `plugins/` are core frontend surface and always
|
|||||||
## Size
|
## Size
|
||||||
|
|
||||||
- test functions defined: **1449** (parametrised cases collect higher)
|
- 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`)
|
||||||
|
|
||||||
|
|||||||
@@ -3811,6 +3811,14 @@
|
|||||||
"purpose": "Asset-detail panel: the most recent agent events for one endpoint",
|
"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"
|
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1asset_id/recentevents"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/endpoints/asset/<asset_id>/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",
|
"method": "GET",
|
||||||
"path": "/api/endpoints/asset/<asset_id>/interfaces",
|
"path": "/api/endpoints/asset/<asset_id>/interfaces",
|
||||||
|
|||||||
@@ -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": {
|
"/api/endpoints/asset/{asset_id}/interfaces": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -64,7 +64,17 @@
|
|||||||
|
|
||||||
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
||||||
<!-- Main Content Grid -->
|
<!-- Main Content Grid -->
|
||||||
|
<!-- Live state before reference data. 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 - only document order decides where
|
||||||
|
it lands. Putting the contributed panels first means endpoint status,
|
||||||
|
services and activity are what a responder meets, and Identity,
|
||||||
|
Hardware and the rest follow. Panels self-order among themselves by
|
||||||
|
their declared position, and self-hide when a site has no plugin
|
||||||
|
answering for this asset type. -->
|
||||||
<div class="content-grid">
|
<div class="content-grid">
|
||||||
|
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" />
|
||||||
|
|
||||||
<!-- Left Column -->
|
<!-- Left Column -->
|
||||||
<div class="content-column">
|
<div class="content-column">
|
||||||
<!-- Identity Section -->
|
<!-- Identity Section -->
|
||||||
@@ -258,9 +268,6 @@
|
|||||||
<!-- Custom Fields -->
|
<!-- Custom Fields -->
|
||||||
<CustomFieldsSection :assetid="computer.assetid" />
|
<CustomFieldsSection :assetid="computer.assetid" />
|
||||||
|
|
||||||
<!-- Warranty -->
|
|
||||||
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" />
|
|
||||||
|
|
||||||
<!-- All relationships (controls, defaultprinter, ...) -->
|
<!-- All relationships (controls, defaultprinter, ...) -->
|
||||||
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
|
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from shopdb.api import (
|
|||||||
ErrorCodes, get_pagination_params, paginate_query, require_permission,
|
ErrorCodes, get_pagination_params, paginate_query, require_permission,
|
||||||
require_role, caller_holds, mint_service_token, list_service_tokens,
|
require_role, caller_holds, mint_service_token, list_service_tokens,
|
||||||
revoke_service_tokens,
|
revoke_service_tokens,
|
||||||
AuditLog, collector_authorized,
|
AuditLog, collector_authorized, User,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..models import EndpointState, EndpointEvent, EndpointCommand
|
from ..models import EndpointState, EndpointEvent, EndpointCommand
|
||||||
@@ -490,6 +490,84 @@ def panel_recent_events(asset_id: int):
|
|||||||
return success_response(rows)
|
return success_response(rows)
|
||||||
|
|
||||||
|
|
||||||
|
@endpoints_bp.route('/asset/<int:asset_id>/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/<int:asset_id>/interfaces', methods=['GET'])
|
@endpoints_bp.route('/asset/<int:asset_id>/interfaces', methods=['GET'])
|
||||||
@require_permission('endpoints.view')
|
@require_permission('endpoints.view')
|
||||||
def panel_interfaces(asset_id: int):
|
def panel_interfaces(asset_id: int):
|
||||||
|
|||||||
@@ -289,10 +289,16 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'position': 13,
|
'position': 13,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'id': 'endpoints-events',
|
# One feed instead of two cards. Events and commands answer the
|
||||||
'title': 'Recent events',
|
# 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'],
|
'assettypes': ['computer'],
|
||||||
'endpoint': '/api/endpoints/asset/{assetid}/recentevents',
|
'endpoint': '/api/endpoints/asset/{assetid}/activity',
|
||||||
'render': 'list',
|
'render': 'list',
|
||||||
'collapsible': True,
|
'collapsible': True,
|
||||||
'status': {
|
'status': {
|
||||||
@@ -314,12 +320,12 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
'meta': [
|
'meta': [
|
||||||
|
{'key': 'detail'},
|
||||||
{'key': 'eventtime', 'label': 'At', 'format': 'date'},
|
{'key': 'eventtime', 'label': 'At', 'format': 'date'},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'empty': 'No recent events.',
|
'empty': 'Nothing recorded for this endpoint yet.',
|
||||||
'collapsedDefault': True,
|
'position': 20,
|
||||||
'position': 90,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'id': 'endpoints-interfaces',
|
'id': 'endpoints-interfaces',
|
||||||
|
|||||||
Reference in New Issue
Block a user