From 3cd1517883eda4b4c3d3ea2f6caed0738a80a313 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 22 May 2026 08:54:43 +0100 Subject: [PATCH] Surface vSphere activity timeline Add a global resource timeline endpoint for provider activity and wire vSphere Activity to VMware timeline changes. Seed mock VMware activity through the same supplemental-change path and keep the relevant resource contract tests current. --- .../v6/internal/subsystems/agent-lifecycle.md | 5 + .../v6/internal/subsystems/api-contracts.md | 7 + .../v6/internal/subsystems/monitoring.md | 7 + .../subsystems/performance-and-scalability.md | 4 + .../internal/subsystems/security-privacy.md | 6 + .../internal/subsystems/storage-recovery.md | 5 + .../internal/subsystems/unified-resources.md | 7 + .../src/api/__tests__/resources.test.ts | 27 ++++ frontend-modern/src/api/resources.ts | 8 + .../src/features/vmware/VmwarePageSurface.tsx | 50 +++++- .../vmware/__tests__/vmwarePageModel.test.ts | 121 ++++++++------- .../src/features/vmware/vmwarePageModel.ts | 144 ++++++++++++++++-- internal/api/resources.go | 140 +++++++++++------ internal/api/resources_test.go | 44 ++++++ internal/api/route_inventory_test.go | 1 + internal/api/router.go | 11 ++ internal/api/router_mock_platforms_test.go | 23 +++ internal/api/router_routes_monitoring.go | 1 + internal/mock/platform_fixtures.go | 23 +++ internal/mock/platform_fixtures_test.go | 10 ++ .../monitoring/monitor_unified_state_test.go | 2 +- .../unifiedresources/code_standards_test.go | 52 ++++--- internal/vmware/activity_changes.go | 10 ++ 23 files changed, 562 insertions(+), 146 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 5f5827d49..a4b595824 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -340,6 +340,11 @@ profile and assignment columns, but embedded table framing must route through handlers and connection ledgers, but they must remain settings/API availability resources and must not create install commands, agent tokens, or host uninstall/stop-monitoring lifecycle actions. + Global resource timeline API changes are likewise adjacent when they touch + shared `internal/api/` route wiring: `/api/resources/timeline` may expose + monitoring-read provider activity for platform pages, but it must not create + setup-token authority, install-command state, fleet command execution, or + agent lifecycle enrollment semantics. Patrol autonomy API changes are likewise adjacent when they touch shared `internal/api/` route wiring: monitor-mode configuration and remediation entitlement payloads remain AI runtime/API-contract owned and must not create diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 85c0b858d..01f10952a 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -568,6 +568,13 @@ the canonical monitored-system blocked payload. `capabilities` and canonical `relationships` alongside recent changes and grouped counts, so frontend detail surfaces consume one governed API payload instead of rebuilding capability or topology context from the list response. + Provider-wide timeline reads are part of the same API contract: + `GET /api/resources/timeline` returns the canonical resource timeline + response shape with an empty `resourceId`, uses the shared `since`, `limit`, + `kind`, `sourceType`, and `sourceAdapter` parser, and remains protected by + the monitoring-read scope. Platform pages may use it for API-authored + provider activity such as vSphere tasks and events, but they must not create + page-local activity stores or a second query vocabulary. 7. Route unified-resource list ordering through `internal/api/resources.go`, `internal/api/contract_test.go`, and the owned unified-resource registry helpers together; list payloads must stay deterministic for equal-name resources by carrying one canonical `name -> type -> id` tie-break across cold seed, REST pagination, and websocket-backed refreshes instead of inheriting map order or page-local re-sorts That same shared API contract also owns the external resource `type`, canonical display name, and cluster identity published through `/api/resources` and `/api/state`; the websocket/state hydrate path must not emit legacy aliases or raw store labels once the unified resource contract has normalized them. Realtime `/api/state` and websocket `resources` snapshots must also collapse diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 8727078b8..b613442ab 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -142,6 +142,13 @@ truth for live infrastructure data. on the owning top-level TrueNAS system resource instead of inventing a generic service resource type or rendering services as Docker/container rows. +17. Add or change provider supplemental platform activity through the + provider-owned supplemental-change path and the canonical mock fixture + graph together. vSphere task/event activity must be authored by the VMware + provider or VMware mock fixture graph as `activity` resource changes with + `platform_event` provenance, then recorded by monitoring's supplemental + resource-change bridge. Monitoring must not create a frontend-only VMware + activity fixture or bypass the unified resource-change store. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 5e25ebd96..47dc521cc 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -185,6 +185,10 @@ regression protection. the same startup-only router rule: env opt-in may configure monitoring-owned checker/collector callbacks, but normal protected request setup must not run `pct`, scan LXC guests, or collect Docker inventory. + Global resource timeline routing follows the same protected-request hot-path + rule: `/api/resources/timeline` registration may wire the authenticated + handler, but router setup and auth gating must not execute resource-change + store reads or provider activity scans before the handler owns the request. Agent command-token validation follows the same bounded router rule: `internal/api/agent_exec_token_binding.go` may validate against the in-memory API token registry and persist a single first-use binding for a diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index a1f06dff6..a148c0f71 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -142,6 +142,12 @@ controls as normal product settings. pinned-fingerprint TLS clients keep one fail-closed security floor. 9. Change operator-facing Resource Privacy/Data Handling posture through `frontend-modern/src/components/Settings/DataHandlingPanel.tsx` and `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts` together so resource classification, handling-boundary, redaction copy, and the route-backed/hidden-sidebar presentation stay governed as a trust surface. 10. Change inside-guest runtime collection boundaries through `docs/AGENT_SECURITY.md`, `docs/UNIFIED_AGENT.md`, `cmd/pulse-agent/main.go`, `internal/api/router.go`, and `internal/config/config.go` together. Docker / Podman inventory inside a VM or LXC may come from a guest-agent or explicitly reported guest data; LXC Docker inventory may also be collected by a Proxmox host agent only through explicit server opt-in, with optional VMID allowlisting and a minimal summary command set that avoids `docker inspect`, environment, mount, file, command, and process collection. Local Unified Agent Docker / Podman disables must not be reversed by remote profile configuration. + Global resource timeline reads through `/api/resources/timeline` are + adjacent monitoring-read surfaces, not a privacy bypass. Provider activity + filters may expose backend-authored task/event metadata, but the endpoint + must keep normal API auth, resource-policy redaction, and inside-guest + runtime collection limits intact rather than expanding what collectors are + allowed to gather. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index cf3128dec..4e068a345 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -386,6 +386,11 @@ a separate Docker-only or TrueNAS-local inventory path. app-container/resource context only as workload inventory. It must not be reinterpreted as backup freshness, restore coverage, or storage protection evidence for the LXC guest. + Global resource timeline routing may also pass through shared + `internal/api/` handlers. Storage and recovery surfaces may read canonical + timeline records as adjacent evidence, but they must not reinterpret an + unscoped `/api/resources/timeline` provider activity row as backup + freshness, restore coverage, storage protection, or remediation authority. That same adjacent API/security boundary owns CSRF replacement-token concurrency for browser mutations. Storage and recovery forms may benefit from the shared retry behavior when parallel requests receive replacement diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index bf323c274..d187fcf8c 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -177,6 +177,13 @@ reintroduce false Swarm capability surfaces. route type. Docker host resources must likewise prefer the reported agent id over host labels when building host-level Discovery targets, so detail drawers, websocket hydration, and API lookups use the same identity. + The global resource timeline is also owned at this boundary. `GET + /api/resources/timeline` may expose provider-wide `ResourceChange` records + for platform pages before a single resource drawer is selected, but those + records must still come from the canonical resource-change store and use + the same filter parser as per-resource timelines. Relationship-aware + expansion remains a per-resource timeline behavior; unscoped provider + activity must not infer related resources in the frontend. ## Extension Points 1. Add new resource types and identity fields in `internal/unifiedresources/types.go` diff --git a/frontend-modern/src/api/__tests__/resources.test.ts b/frontend-modern/src/api/__tests__/resources.test.ts index 48126528e..7f4a75274 100644 --- a/frontend-modern/src/api/__tests__/resources.test.ts +++ b/frontend-modern/src/api/__tests__/resources.test.ts @@ -131,6 +131,33 @@ describe('ResourceAPI', () => { ); }); + it('fetches the global resource timeline with canonical filters', async () => { + vi.mocked(apiFetchJSON).mockResolvedValueOnce({ + resourceId: '', + recentChanges: [{ id: 'activity-1' }], + count: 1, + } as any); + + const result = await ResourceAPI.getGlobalTimeline({ + limit: 100, + kind: 'activity', + sourceType: 'platform_event', + sourceAdapter: 'vmware_adapter', + }); + + expect(apiFetchJSON).toHaveBeenCalledWith( + '/api/resources/timeline?limit=100&kind=activity&sourceType=platform_event&sourceAdapter=vmware_adapter', + { + cache: 'no-store', + }, + ); + expect(result).toEqual({ + resourceId: '', + recentChanges: [{ id: 'activity-1' }], + count: 1, + }); + }); + it('preserves timeline filters when the time window is valid', async () => { vi.mocked(apiFetchJSON).mockResolvedValueOnce({ resourceId: 'vm:42', diff --git a/frontend-modern/src/api/resources.ts b/frontend-modern/src/api/resources.ts index dda9079c0..6a96be185 100644 --- a/frontend-modern/src/api/resources.ts +++ b/frontend-modern/src/api/resources.ts @@ -65,6 +65,14 @@ const fetchFacet = async (url: string): Promise => }); export class ResourceAPI { + static async getGlobalTimeline( + options?: ResourceTimelineQueryOptions, + ): Promise { + return fetchFacet( + `/api/resources/timeline${buildTimelineQuery(options)}`, + ); + } + static async getTimeline( resourceId: string, options?: ResourceTimelineQueryOptions, diff --git a/frontend-modern/src/features/vmware/VmwarePageSurface.tsx b/frontend-modern/src/features/vmware/VmwarePageSurface.tsx index 52e8dae70..3b87b103a 100644 --- a/frontend-modern/src/features/vmware/VmwarePageSurface.tsx +++ b/frontend-modern/src/features/vmware/VmwarePageSurface.tsx @@ -1,6 +1,7 @@ import { useLocation } from '@solidjs/router'; import CpuIcon from 'lucide-solid/icons/cpu'; -import { Show, createMemo, type Accessor } from 'solid-js'; +import { Show, createMemo, createResource, type Accessor } from 'solid-js'; +import { ResourceAPI } from '@/api/resources'; import { useUnifiedResources } from '@/hooks/useUnifiedResources'; import { PlatformErrorState, @@ -39,7 +40,19 @@ export function VmwarePageSurface() { const segment = location.pathname.split('/').filter(Boolean)[1] as VmwarePageTabId | undefined; return segment && VALID_TABS.has(segment) ? segment : 'overview'; }); - const model = createMemo(() => buildVmwarePageModel(resources())); + const [activityTimeline, { refetch: refetchActivityTimeline }] = createResource( + () => (activeTab() === 'activity' ? 'vmware-activity' : undefined), + async () => { + const response = await ResourceAPI.getGlobalTimeline({ + limit: 100, + kind: 'activity', + sourceType: 'platform_event', + sourceAdapter: 'vmware_adapter', + }); + return response.recentChanges ?? []; + }, + ); + const model = createMemo(() => buildVmwarePageModel(resources(), activityTimeline() ?? [])); return (
@@ -91,12 +104,33 @@ export function VmwarePageSurface() { /> - + 0} + fallback={ + void refetchActivityTimeline()} + /> + } + > + 0} + fallback={ + + } + > + + + diff --git a/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts b/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts index 316a26ba0..6cfa30a8b 100644 --- a/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts +++ b/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts @@ -253,7 +253,12 @@ describe('vmwarePageModel', () => { type: 'vm', name: 'warehouse-api-01', displayName: 'warehouse-api-01', + canonicalIdentity: { + primaryId: 'vc-1:vm:vm-201', + aliases: ['vc-1:vm:vm-201', 'vm-201'], + }, vmware: { + connectionId: 'vc-1', entityType: 'vm', managedObjectId: 'vm-201', connectionName: 'lab-vcenter', @@ -261,65 +266,67 @@ describe('vmwarePageModel', () => { datacenterName: 'Primary DC', clusterName: 'Production Cluster', }, - recentChanges: [ - { - id: 'activity-task-reconfigure', - observedAt: '2026-05-21T10:15:00Z', - occurredAt: '2026-05-21T10:15:00Z', - resourceId: 'vm-201', - kind: 'activity', - sourceType: 'platform_event', - sourceAdapter: 'vmware_adapter', - confidence: 'high', - reason: 'Reconfigure virtual machine (error)', - metadata: { - activity_type: 'vmware_task', - activity_native_id: 'task-901', - activity_title: 'Reconfigure virtual machine', - activity_state: 'error', - activity_message: 'Permission denied while reconfiguring VM', - vmwareTaskDescription: 'Reconfigure virtual machine CPU reservation', - vmwareManagedObjectId: 'vm-201', - vmwareEntityType: 'vm', - }, - }, - { - id: 'activity-event-powered-on', - observedAt: '2026-05-21T10:05:00Z', - occurredAt: '2026-05-21T10:05:00Z', - resourceId: 'vm-201', - kind: 'activity', - sourceType: 'platform_event', - sourceAdapter: 'vmware_adapter', - confidence: 'high', - actor: 'administrator@vsphere.local', - reason: 'VmPoweredOnEvent', - metadata: { - activity_type: 'vmware_event', - activity_native_id: 'event-501', - activity_title: 'VmPoweredOnEvent', - activity_message: 'Virtual machine warehouse-api-01 was powered on', - vmwareEventType: 'VmPoweredOnEvent', - vmwareEventMessage: 'Virtual machine warehouse-api-01 was powered on', - vmwareEventUser: 'administrator@vsphere.local', - vmwareManagedObjectId: 'vm-201', - vmwareEntityType: 'vm', - }, - }, - { - id: 'activity-agent', - observedAt: '2026-05-21T10:20:00Z', - resourceId: 'vm-201', - kind: 'activity', - sourceType: 'agent_action', - sourceAdapter: 'agent:ops-helper', - confidence: 'high', - reason: 'Agent note', - }, - ], }); + const activityChanges = [ + { + id: 'activity-task-reconfigure', + observedAt: '2026-05-21T10:15:00Z', + occurredAt: '2026-05-21T10:15:00Z', + resourceId: 'vc-1:vm:vm-201', + kind: 'activity', + sourceType: 'platform_event', + sourceAdapter: 'vmware_adapter', + confidence: 'high', + reason: 'Reconfigure virtual machine (error)', + metadata: { + activity_type: 'vmware_task', + activity_native_id: 'task-901', + activity_title: 'Reconfigure virtual machine', + activity_state: 'error', + activity_message: 'Permission denied while reconfiguring VM', + vmwareConnectionId: 'vc-1', + vmwareTaskDescription: 'Reconfigure virtual machine CPU reservation', + vmwareManagedObjectId: 'vm-201', + vmwareEntityType: 'vm', + }, + }, + { + id: 'activity-event-powered-on', + observedAt: '2026-05-21T10:05:00Z', + occurredAt: '2026-05-21T10:05:00Z', + resourceId: 'vc-1:vm:vm-201', + kind: 'activity', + sourceType: 'platform_event', + sourceAdapter: 'vmware_adapter', + confidence: 'high', + actor: 'administrator@vsphere.local', + reason: 'VmPoweredOnEvent', + metadata: { + activity_type: 'vmware_event', + activity_native_id: 'event-501', + activity_title: 'VmPoweredOnEvent', + activity_message: 'Virtual machine warehouse-api-01 was powered on', + vmwareConnectionId: 'vc-1', + vmwareEventType: 'VmPoweredOnEvent', + vmwareEventMessage: 'Virtual machine warehouse-api-01 was powered on', + vmwareEventUser: 'administrator@vsphere.local', + vmwareManagedObjectId: 'vm-201', + vmwareEntityType: 'vm', + }, + }, + { + id: 'activity-agent', + observedAt: '2026-05-21T10:20:00Z', + resourceId: 'vc-1:vm:vm-201', + kind: 'activity', + sourceType: 'agent_action', + sourceAdapter: 'agent:ops-helper', + confidence: 'high', + reason: 'Agent note', + }, + ] as const; - const rows = buildVmwarePageModel([vm]).activity; + const rows = buildVmwarePageModel([vm], [...activityChanges]).activity; expect(mapVmwareActivityStateBucket('error')).toBe('failed'); expect(mapVmwareActivityStateBucket('success')).toBe('success'); diff --git a/frontend-modern/src/features/vmware/vmwarePageModel.ts b/frontend-modern/src/features/vmware/vmwarePageModel.ts index 3dc55bbdc..6265ca1be 100644 --- a/frontend-modern/src/features/vmware/vmwarePageModel.ts +++ b/frontend-modern/src/features/vmware/vmwarePageModel.ts @@ -95,7 +95,10 @@ export type VmwareActivityRow = { sortTime: number; }; -export function buildVmwarePageModel(resources: Resource[]): VmwarePageModel { +export function buildVmwarePageModel( + resources: Resource[], + activityChanges: ResourceChange[] = [], +): VmwarePageModel { const vmwareResources = resources.filter( (resource) => isVmwarePlatform(resource) && VMWARE_RESOURCE_TYPES.has(resource.type), ); @@ -111,7 +114,7 @@ export function buildVmwarePageModel(resources: Resource[]): VmwarePageModel { ) .sort(compareVmwareDatastores); const incidents = buildVmwareIncidentRows(vmwareResources); - const activity = buildVmwareActivityRows(vmwareResources); + const activity = buildVmwareActivityRows(vmwareResources, activityChanges); return { resources: vmwareResources, @@ -142,6 +145,24 @@ const metadataString = ( return ''; }; +const addLookupKey = (keys: Set, value: unknown): void => { + const key = normalize(value); + if (key) keys.add(key); +}; + +const vmwareSourceAlias = ( + connectionId: unknown, + entityType: unknown, + managedObjectId: unknown, +): string => { + const parts = [ + trimString(connectionId), + trimString(entityType), + trimString(managedObjectId), + ].filter(Boolean); + return parts.join(':'); +}; + const vmwareDatastoreDisplayName = (resource: Resource): string => resource.displayName?.trim() || resource.name?.trim() || resource.id; @@ -456,6 +477,75 @@ const parseActivitySortTime = (change: ResourceChange): number => { return Number.isFinite(parsed) ? parsed : 0; }; +const vmwareActivityResourceKeys = (resource: Resource): Set => { + const keys = new Set(); + addLookupKey(keys, resource.id); + addLookupKey(keys, resource.canonicalIdentity?.primaryId); + for (const alias of resource.canonicalIdentity?.aliases ?? []) { + addLookupKey(keys, alias); + } + const sourceAlias = vmwareSourceAlias( + resource.vmware?.connectionId, + resource.vmware?.entityType, + resource.vmware?.managedObjectId, + ); + addLookupKey(keys, sourceAlias); + addLookupKey(keys, resource.vmware?.managedObjectId); + if (sourceAlias && resource.type === 'storage') { + addLookupKey(keys, `storage:${sourceAlias}`); + } + return keys; +}; + +const vmwareActivityChangeKeys = (change: ResourceChange): Set => { + const keys = new Set(); + addLookupKey(keys, change.resourceId); + const metadata = change.metadata; + const sourceAlias = vmwareSourceAlias( + metadataString(metadata, 'vmwareConnectionId'), + metadataString(metadata, 'vmwareEntityType'), + metadataString(metadata, 'vmwareManagedObjectId'), + ); + addLookupKey(keys, sourceAlias); + addLookupKey(keys, metadataString(metadata, 'vmwareManagedObjectId')); + if (sourceAlias) { + addLookupKey(keys, `storage:${sourceAlias}`); + } + return keys; +}; + +const buildVmwareActivityResourceIndex = (resources: Resource[]): Map => { + const index = new Map(); + for (const resource of resources) { + for (const key of vmwareActivityResourceKeys(resource)) { + if (!index.has(key)) { + index.set(key, resource); + } + } + } + return index; +}; + +const resolveVmwareActivityResource = ( + change: ResourceChange, + resourceIndex: Map, +): Resource | undefined => { + for (const key of vmwareActivityChangeKeys(change)) { + const resource = resourceIndex.get(key); + if (resource) return resource; + } + return undefined; +}; + +const activityChangeDedupeKey = (resource: Resource, change: ResourceChange): string => + [ + resource.id, + trimString(change.id), + trimString(change.resourceId), + trimString(change.observedAt), + trimString(change.reason), + ].join('|'); + const buildActivityRow = ( resource: Resource, change: ResourceChange, @@ -510,20 +600,42 @@ const buildActivityRow = ( }; }; -export function buildVmwareActivityRows(resources: Resource[]): VmwareActivityRow[] { - return resources - .flatMap((resource) => - (resource.recentChanges ?? []) - .filter(isVmwareActivityChange) - .map((change, index) => buildActivityRow(resource, change, index)), - ) - .sort((left, right) => { - const timeDelta = right.sortTime - left.sortTime; - if (timeDelta !== 0) return timeDelta; - const resourceDelta = left.resourceName.localeCompare(right.resourceName); - if (resourceDelta !== 0) return resourceDelta; - return left.id.localeCompare(right.id); - }); +export function buildVmwareActivityRows( + resources: Resource[], + activityChanges: ResourceChange[] = [], +): VmwareActivityRow[] { + const resourceIndex = buildVmwareActivityResourceIndex(resources); + const seen = new Set(); + const rows: VmwareActivityRow[] = []; + + const appendRow = (resource: Resource, change: ResourceChange) => { + if (!isVmwareActivityChange(change)) return; + const key = activityChangeDedupeKey(resource, change); + if (seen.has(key)) return; + seen.add(key); + rows.push(buildActivityRow(resource, change, rows.length)); + }; + + for (const resource of resources) { + for (const change of resource.recentChanges ?? []) { + appendRow(resource, change); + } + } + + for (const change of activityChanges) { + const resource = resolveVmwareActivityResource(change, resourceIndex); + if (resource) { + appendRow(resource, change); + } + } + + return rows.sort((left, right) => { + const timeDelta = right.sortTime - left.sortTime; + if (timeDelta !== 0) return timeDelta; + const resourceDelta = left.resourceName.localeCompare(right.resourceName); + if (resourceDelta !== 0) return resourceDelta; + return left.id.localeCompare(right.id); + }); } const vmwareDatastoreSearchHaystack = (resource: Resource): string => diff --git a/internal/api/resources.go b/internal/api/resources.go index fdd448240..9800bbbb7 100644 --- a/internal/api/resources.go +++ b/internal/api/resources.go @@ -301,8 +301,49 @@ type resourceFacetBundleResponse struct { Counts resourceFacetCountsResponse `json:"counts"` } +type resourceTimelineQueryOptions struct { + since time.Time + limit int + filters unified.ResourceChangeFilters +} + +func parseResourceTimelineQuery(r *http.Request, defaultLimit int, includeRelated bool) (resourceTimelineQueryOptions, string) { + query := resourceTimelineQueryOptions{ + limit: defaultLimit, + } + + if raw := strings.TrimSpace(r.URL.Query().Get("since")); raw != "" { + parsed, parseErr := time.Parse(time.RFC3339, raw) + if parseErr != nil { + return query, "Invalid since value" + } + query.since = parsed.UTC() + } + if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { + parsed, parseErr := strconv.Atoi(raw) + if parseErr != nil || parsed <= 0 { + return query, "Invalid limit value" + } + query.limit = parsed + } + + filters, err := unified.ParseResourceChangeFilters(r.URL.Query()["kind"], r.URL.Query()["sourceType"], r.URL.Query()["sourceAdapter"]) + if err != nil { + return query, err.Error() + } + if includeRelated { + filters.IncludeRelated = true + } + query.filters = filters + return query, "" +} + // HandleResourceRoutes dispatches nested resource routes. func (h *ResourceHandlers) HandleResourceRoutes(w http.ResponseWriter, r *http.Request) { + if strings.TrimSuffix(r.URL.Path, "/") == "/api/resources/timeline" { + h.HandleListResourceTimeline(w, r) + return + } if strings.HasSuffix(r.URL.Path, "/facets") { h.HandleGetResourceFacets(w, r) return @@ -368,30 +409,14 @@ func (h *ResourceHandlers) HandleGetResourceFacets(w http.ResponseWriter, r *htt return } - since := time.Time{} - if raw := strings.TrimSpace(r.URL.Query().Get("since")); raw != "" { - parsed, parseErr := time.Parse(time.RFC3339, raw) - if parseErr != nil { - http.Error(w, "Invalid since value", http.StatusBadRequest) - return - } - since = parsed.UTC() - } - limit := 25 - if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { - parsed, parseErr := strconv.Atoi(raw) - if parseErr != nil || parsed <= 0 { - http.Error(w, "Invalid limit value", http.StatusBadRequest) - return - } - limit = parsed - } - filters, err := unified.ParseResourceChangeFilters(r.URL.Query()["kind"], r.URL.Query()["sourceType"], r.URL.Query()["sourceAdapter"]) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + timelineQuery, parseError := parseResourceTimelineQuery(r, 25, true) + if parseError != "" { + http.Error(w, parseError, http.StatusBadRequest) return } - filters.IncludeRelated = true + since := timelineQuery.since + limit := timelineQuery.limit + filters := timelineQuery.filters recentChanges, err := store.GetRecentChangesFiltered(resourceID, since, limit, filters) if err != nil { @@ -500,6 +525,49 @@ func (h *ResourceHandlers) HandleGetMetrics(w http.ResponseWriter, r *http.Reque json.NewEncoder(w).Encode(resource.Metrics) } +// HandleListResourceTimeline handles GET /api/resources/timeline. +func (h *ResourceHandlers) HandleListResourceTimeline(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + orgID := GetOrgID(r.Context()) + store, err := h.getStore(orgID) + if err != nil { + http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) + return + } + + resourceID := "" + timelineQuery, parseError := parseResourceTimelineQuery(r, 100, false) + if parseError != "" { + http.Error(w, parseError, http.StatusBadRequest) + return + } + since := timelineQuery.since + limit := timelineQuery.limit + filters := timelineQuery.filters + + changes, err := store.GetRecentChangesFiltered(resourceID, since, limit, filters) + if err != nil { + http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) + return + } + changeCount, err := store.CountRecentChangesFiltered(resourceID, since, filters) + if err != nil { + http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "resourceId": resourceID, + "recentChanges": changes, + "count": changeCount, + }) +} + // HandleGetResourceTimeline handles GET /api/resources/{id}/timeline. func (h *ResourceHandlers) HandleGetResourceTimeline(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -523,30 +591,14 @@ func (h *ResourceHandlers) HandleGetResourceTimeline(w http.ResponseWriter, r *h return } - since := time.Time{} - if raw := strings.TrimSpace(r.URL.Query().Get("since")); raw != "" { - parsed, parseErr := time.Parse(time.RFC3339, raw) - if parseErr != nil { - http.Error(w, "Invalid since value", http.StatusBadRequest) - return - } - since = parsed.UTC() - } - limit := 100 - if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { - parsed, parseErr := strconv.Atoi(raw) - if parseErr != nil || parsed <= 0 { - http.Error(w, "Invalid limit value", http.StatusBadRequest) - return - } - limit = parsed - } - filters, err := unified.ParseResourceChangeFilters(r.URL.Query()["kind"], r.URL.Query()["sourceType"], r.URL.Query()["sourceAdapter"]) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + timelineQuery, parseError := parseResourceTimelineQuery(r, 100, true) + if parseError != "" { + http.Error(w, parseError, http.StatusBadRequest) return } - filters.IncludeRelated = true + since := timelineQuery.since + limit := timelineQuery.limit + filters := timelineQuery.filters changes, err := store.GetRecentChangesFiltered(resourceID, since, limit, filters) if err != nil { diff --git a/internal/api/resources_test.go b/internal/api/resources_test.go index 4048f55be..474fcb557 100644 --- a/internal/api/resources_test.go +++ b/internal/api/resources_test.go @@ -1218,6 +1218,24 @@ func TestResourceGetFacetsAndTimeline(t *testing.T) { }); err != nil { t.Fatalf("RecordChange node direct: %v", err) } + if err := store.RecordChange(unified.ResourceChange{ + ID: "chg-vmware-activity", + ResourceID: "vc-1:vm:vm-201", + ObservedAt: now.Add(-15 * time.Second), + Kind: unified.ChangeActivity, + SourceType: unified.SourcePlatformEvent, + SourceAdapter: unified.AdapterVMware, + Confidence: unified.ConfidenceHigh, + Reason: "Reconfigure virtual machine (success)", + Metadata: map[string]any{ + unified.MetadataActivityType: "vmware_task", + "vmwareConnectionId": "vc-1", + "vmwareEntityType": "vm", + "vmwareManagedObjectId": "vm-201", + }, + }); err != nil { + t.Fatalf("RecordChange VMware activity: %v", err) + } t.Run("facets", func(t *testing.T) { rec := httptest.NewRecorder() @@ -1368,6 +1386,32 @@ func TestResourceGetFacetsAndTimeline(t *testing.T) { } }) + t.Run("global filtered timeline", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/resources/timeline?kind=activity&sourceType=platform_event&sourceAdapter=vmware_adapter&limit=10", nil) + h.HandleListResourceTimeline(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + var payload struct { + ResourceID string `json:"resourceId"` + RecentChanges []unified.ResourceChange `json:"recentChanges"` + Count int `json:"count"` + } + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode global timeline: %v", err) + } + if payload.ResourceID != "" || payload.Count != 1 || len(payload.RecentChanges) != 1 { + t.Fatalf("unexpected global timeline payload: %#v", payload) + } + if payload.RecentChanges[0].ID != "chg-vmware-activity" { + t.Fatalf("unexpected global timeline change: %#v", payload.RecentChanges[0]) + } + if got := payload.RecentChanges[0].Metadata[unified.MetadataActivityType]; got != "vmware_task" { + t.Fatalf("unexpected VMware activity metadata: %#v", payload.RecentChanges[0].Metadata) + } + }) + t.Run("filtered facets", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:42/facets?kind=restart&sourceType=platform_event", nil) diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index ec9c6f000..902e441b1 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -408,6 +408,7 @@ var allRouteAllowlist = []string{ "/api/resources/storage-summary", "/api/resources/k8s/namespaces", "/api/resources/stats", + "/api/resources/timeline", "/api/resources/", "/api/resources/{id}/facets", "/api/resources/{id}/timeline", diff --git a/internal/api/router.go b/internal/api/router.go index d9fb18386..f58f37a30 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -10421,6 +10421,17 @@ func (a mockSupplementalRecordsAdapter) SupplementalRecords(_ *monitoring.Monito return a.GetCurrentRecordsForOrg(orgID) } +func (a mockSupplementalRecordsAdapter) GetCurrentChangesForOrg(orgID string) []unifiedresources.ResourceChange { + if strings.TrimSpace(orgID) != "" && strings.TrimSpace(orgID) != "default" { + return nil + } + return mock.SupplementalChanges(a.source) +} + +func (a mockSupplementalRecordsAdapter) SupplementalChanges(_ *monitoring.Monitor, orgID string) []unifiedresources.ResourceChange { + return a.GetCurrentChangesForOrg(orgID) +} + func (a mockSupplementalRecordsAdapter) SnapshotOwnedSources() []unifiedresources.DataSource { normalized := normalizeDataSourceAlias(a.source) if normalized == "" { diff --git a/internal/api/router_mock_platforms_test.go b/internal/api/router_mock_platforms_test.go index f91e98d4a..2edbfe836 100644 --- a/internal/api/router_mock_platforms_test.go +++ b/internal/api/router_mock_platforms_test.go @@ -70,6 +70,29 @@ func TestRouterMockMode_SeedsTrueNASAndVMwareSupplementalResources(t *testing.T) } } +func TestRouterMockMode_SeedsVMwareSupplementalActivity(t *testing.T) { + previous := mock.IsMockEnabled() + mock.SetEnabled(true) + t.Cleanup(func() { + mock.SetEnabled(previous) + }) + + adapter := mockSupplementalRecordsAdapter{source: unified.SourceVMware} + changes := adapter.SupplementalChanges(nil, "default") + if len(changes) == 0 { + t.Fatal("expected mock VMware supplemental activity changes") + } + if changes[0].Kind != unified.ChangeActivity || changes[0].SourceAdapter != unified.AdapterVMware { + t.Fatalf("unexpected mock VMware activity change: %#v", changes[0]) + } + if changes[0].Metadata[unified.MetadataActivityType] == "" { + t.Fatalf("expected VMware activity metadata, got %#v", changes[0].Metadata) + } + if got := adapter.SupplementalChanges(nil, "org-b"); len(got) != 0 { + t.Fatalf("expected tenant-scoped mock activity to be empty for non-default org, got %#v", got) + } +} + func TestRouterMockMode_RestoresPlatformFeatureFlagsAfterDisable(t *testing.T) { t.Setenv(truenas.FeatureTrueNAS, "false") t.Setenv(vmware.FeatureVMware, "false") diff --git a/internal/api/router_routes_monitoring.go b/internal/api/router_routes_monitoring.go index 7d55aed39..ff876dce3 100644 --- a/internal/api/router_routes_monitoring.go +++ b/internal/api/router_routes_monitoring.go @@ -37,6 +37,7 @@ func (r *Router) registerMonitoringResourceRoutes( r.mux.HandleFunc("/api/resources/storage-summary", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleStorageSummary))) r.mux.HandleFunc("/api/resources/stats", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleStats))) r.mux.HandleFunc("/api/resources/k8s/namespaces", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleK8sNamespaces))) + r.mux.HandleFunc("/api/resources/timeline", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleListResourceTimeline))) r.mux.HandleFunc("/api/resources/{id}/facets", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleGetResourceFacets))) r.mux.HandleFunc("/api/resources/{id}/timeline", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleGetResourceTimeline))) // Per-resource operator-set state. GET is read; PUT/DELETE are diff --git a/internal/mock/platform_fixtures.go b/internal/mock/platform_fixtures.go index bdd1ea57e..9575fbb3c 100644 --- a/internal/mock/platform_fixtures.go +++ b/internal/mock/platform_fixtures.go @@ -135,6 +135,20 @@ func SupplementalRecords(source unifiedresources.DataSource) []unifiedresources. } } +func SupplementalChanges(source unifiedresources.DataSource) []unifiedresources.ResourceChange { + if IsMockEnabled() { + return CurrentFixtureGraph().SupplementalChanges(source) + } + + platformFixtures := defaultPlatformFixtures() + switch normalizeSupplementalSource(source) { + case unifiedresources.SourceVMware: + return vmware.FixtureActivityChanges(platformFixtures.VMware) + default: + return nil + } +} + func PlatformOwnedSources() []unifiedresources.DataSource { return []unifiedresources.DataSource{ unifiedresources.SourceTrueNAS, @@ -207,6 +221,15 @@ func (g FixtureGraph) SupplementalRecords(source unifiedresources.DataSource) [] } } +func (g FixtureGraph) SupplementalChanges(source unifiedresources.DataSource) []unifiedresources.ResourceChange { + switch normalizeSupplementalSource(source) { + case unifiedresources.SourceVMware: + return vmware.FixtureActivityChanges(g.PlatformFixtures.VMware) + default: + return nil + } +} + func trueNASCollectedAt(fixtures truenas.FixtureSnapshot) time.Time { if !fixtures.CollectedAt.IsZero() { return fixtures.CollectedAt diff --git a/internal/mock/platform_fixtures_test.go b/internal/mock/platform_fixtures_test.go index 57360c549..bb574cc4f 100644 --- a/internal/mock/platform_fixtures_test.go +++ b/internal/mock/platform_fixtures_test.go @@ -105,6 +105,16 @@ func TestSupplementalRecordsNormalizesVMwareAlias(t *testing.T) { } } +func TestSupplementalChangesNormalizesVMwareAlias(t *testing.T) { + changes := SupplementalChanges(unifiedresources.DataSource("vmware-vsphere")) + if len(changes) == 0 { + t.Fatal("expected activity changes for vmware-vsphere alias") + } + if changes[0].Kind != unifiedresources.ChangeActivity || changes[0].SourceAdapter != unifiedresources.AdapterVMware { + t.Fatalf("unexpected VMware activity change: %#v", changes[0]) + } +} + func TestFixtureGraphProjectsAvailabilityFixturesAsNetworkEndpoints(t *testing.T) { now := time.Date(2026, time.May, 6, 12, 0, 0, 0, time.UTC) diff --git a/internal/monitoring/monitor_unified_state_test.go b/internal/monitoring/monitor_unified_state_test.go index e3fac51ae..955af2779 100644 --- a/internal/monitoring/monitor_unified_state_test.go +++ b/internal/monitoring/monitor_unified_state_test.go @@ -326,7 +326,7 @@ func TestMonitorUnifiedResourceSnapshotFallsBackToSnapshotWhenStoreEmpty(t *test } func TestMonitorUnifiedResourceSnapshotIncludesRecentStandaloneHostContinuity(t *testing.T) { - now := time.Date(2026, 5, 13, 14, 30, 0, 0, time.UTC) + now := time.Now().UTC().Truncate(time.Second) store := config.NewHostContinuityStore(t.TempDir(), nil) if err := store.Upsert(config.HostContinuityEntry{ HostID: "host-1", diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index 20768851b..232dcd937 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -342,28 +342,40 @@ func TestCephPoolsProjectThroughCanonicalStoragePath(t *testing.T) { } func TestResourceAPIExposesDedicatedFacetReads(t *testing.T) { - data, err := os.ReadFile(filepath.Join("..", "api", "resources.go")) - if err != nil { - t.Fatalf("failed to read resources.go: %v", err) + requiredSnippets := map[string][]string{ + filepath.Join("..", "api", "resources.go"): { + "HandleGetResourceFacets", + "HandleGetResourceTimeline", + "HandleListResourceTimeline", + "unified.ParseResourceChangeFilters(r.URL.Query()[\"kind\"], r.URL.Query()[\"sourceType\"], r.URL.Query()[\"sourceAdapter\"])", + "filters.IncludeRelated = true", + "GetRecentChangesFiltered(resourceID, since, limit, filters)", + "CountRecentChangesFiltered(resourceID, since, filters)", + "CountRecentChangesByKindFiltered(resourceID, since, filters)", + "CountRecentChangesBySourceTypeFiltered(resourceID, since, filters)", + "sourceAdapter", + `strings.TrimSuffix(r.URL.Path, "/") == "/api/resources/timeline"`, + "strings.HasSuffix(r.URL.Path, \"/facets\")", + "strings.HasSuffix(r.URL.Path, \"/timeline\")", + }, + filepath.Join("..", "..", "frontend-modern", "src", "api", "resources.ts"): { + "static async getGlobalTimeline(", + "`/api/resources/timeline${buildTimelineQuery(options)}`", + "static async getTimeline(", + "static async getFacetBundle(", + }, } - source := string(data) - requiredSnippets := []string{ - "HandleGetResourceFacets", - "HandleGetResourceTimeline", - "unified.ParseResourceChangeFilters(r.URL.Query()[\"kind\"], r.URL.Query()[\"sourceType\"], r.URL.Query()[\"sourceAdapter\"])", - "filters.IncludeRelated = true", - "GetRecentChangesFiltered(resourceID, since, limit, filters)", - "CountRecentChangesFiltered(resourceID, since, filters)", - "CountRecentChangesByKindFiltered(resourceID, since, filters)", - "CountRecentChangesBySourceTypeFiltered(resourceID, since, filters)", - "sourceAdapter", - "strings.HasSuffix(r.URL.Path, \"/facets\")", - "strings.HasSuffix(r.URL.Path, \"/timeline\")", - } - for _, snippet := range requiredSnippets { - if !strings.Contains(source, snippet) { - t.Fatalf("internal/api/resources.go must expose canonical facet read snippet %q", snippet) + for name, snippets := range requiredSnippets { + data, err := os.ReadFile(name) + if err != nil { + t.Fatalf("failed to read %s: %v", name, err) + } + source := string(data) + for _, snippet := range snippets { + if !strings.Contains(source, snippet) { + t.Fatalf("%s must expose canonical resource timeline/facet snippet %q", name, snippet) + } } } } diff --git a/internal/vmware/activity_changes.go b/internal/vmware/activity_changes.go index 1f717a17b..fd0ead315 100644 --- a/internal/vmware/activity_changes.go +++ b/internal/vmware/activity_changes.go @@ -16,6 +16,16 @@ func (p *Provider) ActivityChanges() []unifiedresources.ResourceChange { } snapshot := p.Snapshot() + return activityChangesFromSnapshot(snapshot) +} + +// FixtureActivityChanges projects VMware fixture activity into canonical +// timeline changes without consulting the runtime feature flag. +func FixtureActivityChanges(snapshot InventorySnapshot) []unifiedresources.ResourceChange { + return activityChangesFromSnapshot(&snapshot) +} + +func activityChangesFromSnapshot(snapshot *InventorySnapshot) []unifiedresources.ResourceChange { if snapshot == nil { return nil }