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.
This commit is contained in:
rcourtman 2026-05-22 08:54:43 +01:00
parent 06a73244a2
commit 3cd1517883
23 changed files with 562 additions and 146 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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`

View file

@ -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',

View file

@ -65,6 +65,14 @@ const fetchFacet = async <T>(url: string): Promise<T> =>
});
export class ResourceAPI {
static async getGlobalTimeline(
options?: ResourceTimelineQueryOptions,
): Promise<ResourceTimelineResponse> {
return fetchFacet<ResourceTimelineResponse>(
`/api/resources/timeline${buildTimelineQuery(options)}`,
);
}
static async getTimeline(
resourceId: string,
options?: ResourceTimelineQueryOptions,

View file

@ -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 (
<div data-testid="vmware-page" class="space-y-3">
@ -91,12 +104,33 @@ export function VmwarePageSurface() {
/>
</Show>
<Show when={activeTab() === 'activity'}>
<VsphereActivityTable
activity={model().activity}
emptyIcon={vmwareIcon()}
emptyTitle="No vSphere activity"
emptyDescription="Recent vCenter tasks and events appear here when the vCenter connection reports them."
/>
<Show
when={!activityTimeline.error || model().activity.length > 0}
fallback={
<PlatformErrorState
title="Could not load vSphere activity"
description="Refresh the vSphere activity timeline or check the API connection state."
onRefresh={() => void refetchActivityTimeline()}
/>
}
>
<Show
when={!activityTimeline.loading || model().activity.length > 0}
fallback={
<PlatformTableLoadingState
title="Loading vSphere activity"
description="Pulse is loading recent vCenter tasks and events."
/>
}
>
<VsphereActivityTable
activity={model().activity}
emptyIcon={vmwareIcon()}
emptyTitle="No vSphere activity"
emptyDescription="Recent vCenter tasks and events appear here when the vCenter connection reports them."
/>
</Show>
</Show>
</Show>
</Show>
</Show>

View file

@ -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');

View file

@ -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<string>, 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<string> => {
const keys = new Set<string>();
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<string> => {
const keys = new Set<string>();
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<string, Resource> => {
const index = new Map<string, Resource>();
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<string, Resource>,
): 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<string>();
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 =>

View file

@ -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 {

View file

@ -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)

View file

@ -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",

View file

@ -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 == "" {

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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",

View file

@ -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)
}
}
}
}

View file

@ -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
}