From 7e779a6829a68d4646d29cc0b880ee7217f8ed1d Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Fri, 28 Aug 2026 22:35:16 -0400 Subject: [PATCH] Fleet console: triage band, activity feed, live state first --- docs/openapi.json | 51 +++++ frontend/src/components/AssetTriageBand.vue | 185 ++++++++++++++++++ plugins/computers/frontend/views/PCDetail.vue | 98 ++++++++-- plugins/endpoints/api/routes.py | 101 +++++++++- .../frontend/views/EndpointsList.vue | 23 ++- plugins/endpoints/plugin.py | 18 +- 6 files changed, 448 insertions(+), 28 deletions(-) create mode 100644 frontend/src/components/AssetTriageBand.vue 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/frontend/src/components/AssetTriageBand.vue b/frontend/src/components/AssetTriageBand.vue new file mode 100644 index 00000000..1e85040e --- /dev/null +++ b/frontend/src/components/AssetTriageBand.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/plugins/computers/frontend/views/PCDetail.vue b/plugins/computers/frontend/views/PCDetail.vue index 04efec4d..45cd3647 100644 --- a/plugins/computers/frontend/views/PCDetail.vue +++ b/plugins/computers/frontend/views/PCDetail.vue @@ -49,12 +49,32 @@ {{ computer.locationname }} + + + +
+ +
@@ -125,20 +145,19 @@
- -
-

Remote Access

-
- - None configured -
-
+ - -
+ +

Status

@@ -249,9 +268,6 @@ - - - @@ -285,6 +301,7 @@ import { withBase } from '@/utils/basePath' import { useWarrantyBadge } from '@/composables/warrantyBadge' import LocationMapTooltip from '@/components/LocationMapTooltip.vue' import CustomFieldsSection from '@/components/CustomFieldsSection.vue' +import AssetTriageBand from '@/components/AssetTriageBand.vue' import PluginAssetPanels from '@/components/PluginAssetPanels.vue' import AssetRelationships from '@/components/AssetRelationships.vue' import { useIdentifierFlags } from '@/composables/identifierSettings' @@ -301,6 +318,18 @@ const { isEnabled } = useIdentifierFlags() const loading = ref(true) const computer = ref(null) +// Liveness for the triage band. Null when the endpoints plugin is absent, or +// when the viewer lacks endpoints.view - in either case the chip is simply not +// shown, which is better than rendering "unknown" and implying a fault. +const liveness = ref(null) + +// The computer record's own check-in fields, as opposed to live agent +// telemetry. Empty on an agent-managed PC, populated where the asset-report +// script runs instead. +const hasRecordCheckin = computed(() => { + const record = computer.value?.computer || {} + return Boolean(record.loggedinuser || record.lastreporteddate || record.lastboottime) +}) const installedApps = ref([]) const assetPanels = ref(null) const restarting = ref(false) @@ -317,6 +346,42 @@ const { heroWarranty, warrantyDate: formatWarrantyDate } = useWarrantyBadge(() = // relationships render via the shared AssetRelationships card +async function loadLiveness(assetid) { + if (!assetid) return + try { + const response = await endpointsApi.get(assetid) + const state = response.data.data || {} + // The SERVER decides reporting/quiet/never, because the quiet window is a + // site setting. Guessing a threshold here would disagree with the Fleet + // list about the same endpoint. + const labels = { + reporting: `Reporting, ${relativeAge(state.lastreportedat)}`, + quiet: `Quiet, last seen ${relativeAge(state.lastreportedat)}`, + never: 'Never reported' + } + const levels = { reporting: 'ok', quiet: 'warning', never: 'unknown' } + if (state.status) { + liveness.value = { level: levels[state.status], label: labels[state.status] } + } + } catch (error) { + // 404 = no agent on this PC, 403 = the viewer cannot read telemetry. + // Neither is a page error; the chip just does not appear. + liveness.value = null + } +} + +function relativeAge(value) { + if (!value) return 'never' + const then = new Date(value) + if (isNaN(then)) return 'unknown' + const mins = Math.floor((Date.now() - then.getTime()) / 60000) + if (mins < 1) return 'just now' + if (mins < 60) return `${mins} m ago` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours} h ago` + return `${Math.floor(hours / 24)} d ago` +} + onMounted(async () => { try { const response = await computersApi.get(route.params.id) @@ -329,6 +394,7 @@ onMounted(async () => { installedApps.value = appsResponse.data.data || [] } catch (appError) { } + loadLiveness(computer.value?.assetid) // Warranties load via useWarrantyBadge (watches computer.assetid). } catch (error) { console.error('Error loading computer:', error) diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index 68c8b69a..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 @@ -306,7 +306,13 @@ def _health_for(state, criticalcount): @endpoints_bp.route('/', methods=['GET']) @require_permission('endpoints.view') def get_endpoint(asset_id: int): - """Get the current state for one endpoint by asset id.""" + """Get the current state for one endpoint by asset id. + + Carries the same reporting/quiet/never verdict the fleet list uses. The + quiet window is a site setting, so the SERVER decides it: a caller that + guessed a threshold in the browser would disagree with the fleet list on + the same endpoint the moment a site changed the setting. + """ state = EndpointState.query.filter_by(assetid=asset_id).first() if not state: @@ -316,7 +322,18 @@ def get_endpoint(asset_id: int): http_code=404, ) - return success_response(state.to_dict()) + cutoff = _naive_utcnow() - timedelta(hours=_quiet_hours()) + if state.lastreportedat is None: + status = 'never' + elif state.lastreportedat < cutoff: + status = 'quiet' + else: + status = 'reporting' + + data = state.to_dict() + data['status'] = status + data['uptimedisplay'] = _format_uptime(state.uptimeseconds) + return success_response(data) @endpoints_bp.route('//events', methods=['GET']) @@ -473,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/frontend/views/EndpointsList.vue b/plugins/endpoints/frontend/views/EndpointsList.vue index c2dc9cfe..6cf87bf1 100644 --- a/plugins/endpoints/frontend/views/EndpointsList.vue +++ b/plugins/endpoints/frontend/views/EndpointsList.vue @@ -274,6 +274,11 @@ function changePerPage(newPerPage) { padding: 0.8rem 0.95rem; cursor: pointer; font: inherit; + /* A button does NOT inherit color the way it inherits font, so without this + the tile renders in the browser default black - invisible on the dark + theme's card. The coloured tiles set their own color and hid the bug; the + plain total did not. */ + color: var(--text); text-align: left; } @@ -296,6 +301,21 @@ function changePerPage(newPerPage) { .fleet-tile.warn .n { color: var(--warning-dark); } .fleet-tile.bad .n { color: var(--danger); } +/* Dark mode redefines --success and --warning to bright variants, but NOT the + -dark ones, which stay tuned for a white background. Left alone they render + dark green and dark amber on the dark card. Scoped to the stamped attribute + (the theme store always sets it) rather than prefers-color-scheme, which + would leak dark styling into light mode on a dark-OS machine. */ +[data-theme="dark"] .fleet-tile.ok .n { color: var(--success); } +[data-theme="dark"] .fleet-tile.warn .n { color: var(--warning); } + +.health-pill.ok { color: var(--success-dark); } +.health-pill.warning { color: var(--warning-dark); } +.health-pill.critical { color: var(--danger); } + +[data-theme="dark"] .health-pill.ok { color: var(--success); } +[data-theme="dark"] .health-pill.warning { color: var(--warning); } + .fleet-filter { max-width: 200px; } .status-dot { @@ -324,7 +344,4 @@ function changePerPage(newPerPage) { color: var(--text-light); } -.health-pill.ok { color: var(--success-dark); } -.health-pill.warning { color: var(--warning-dark); } -.health-pill.critical { color: var(--danger); } 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',