From 9c43a48ff0e4605833247353b60bd2bed2c80fee Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 18 Mar 2026 20:29:30 +0000 Subject: [PATCH] Harden unified resource timeline filters --- .../v6/internal/subsystems/agent-lifecycle.md | 4 + .../v6/internal/subsystems/api-contracts.md | 4 + .../internal/subsystems/storage-recovery.md | 4 + .../internal/subsystems/unified-resources.md | 3 + .../src/api/__tests__/resources.test.ts | 35 +++- frontend-modern/src/api/resources.ts | 10 + .../Infrastructure/ResourceDetailDrawer.tsx | 169 ++++++++++++++-- .../ResourceDetailDrawer.history.test.tsx | 131 +++++++++++++ internal/api/resources.go | 87 ++++++++- internal/api/resources_test.go | 53 ++++- internal/unifiedresources/changes.go | 35 ++++ .../unifiedresources/code_standards_test.go | 3 + internal/unifiedresources/store.go | 181 +++++++++++++++++- internal/unifiedresources/store_test.go | 160 ++++++++++++++++ 14 files changed, 844 insertions(+), 35 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 7a50c4b7f..69ed66689 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -96,6 +96,10 @@ The same shared API runtime now also exposes dedicated unified-resource capability, relationship, and timeline reads through `internal/api/resources.go`, but those query surfaces remain owned by the API and unified-resource contracts rather than by lifecycle continuity. +Those same dedicated reads also accept governed timeline filters for change +kind and source type, and the underlying store owns the filtered counts so +agent lifecycle routing still stays on canonical fleet-continuity ownership +instead of re-deriving resource history locally. The dedicated profile client now also routes list, schema, and validation parsing through shared response helpers in `frontend-modern/src/api/agentProfiles.ts`, so profile transport stays aligned with the governed API contract instead of diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 99a169bfd..e89765975 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -123,6 +123,10 @@ The same resource contract now also exposes dedicated facet endpoints for `/api/resources/{id}/capabilities`, `/api/resources/{id}/relationships`, and `/api/resources/{id}/timeline`, so operators can read the graph and change history without depending on a monolithic resource payload. +Those history reads now also accept governed `kind` and `sourceType` query +filters, and the backend store owns the corresponding filtered counts, so the +timeline contract can narrow by change class without inventing a frontend-only +slice of the graph. The same API contract now also exposes the unified-resource control-plane history through dedicated enterprise audit reads. The action, lifecycle, and export history endpoints live in `internal/api/activity_audit_handlers.go` and diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 7c37189ed..28b0c4657 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -120,6 +120,10 @@ The same shared API runtime now also exposes dedicated `/api/resources/{id}/timeline` reads, but storage and recovery must continue to treat those as adjacent governed API ownership rather than storage/recovery timeline ownership. +Those resource timeline reads now also accept governed kind and source-type +filters, with filtered history counts owned by the unified-resource store so +storage and recovery views can consume the same canonical history contract +without re-deriving their own timeline slices. The shared unified-resource consumer hook now also preserves `capabilities`, `relationships`, `recentChanges`, `facetCounts`, `policy`, and `aiSafeSummary` fields when storage and recovery surfaces read unified resources, so those diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 4838adaa4..95c884ba2 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -197,6 +197,9 @@ Relationship cards in that drawer also surface `lastSeenAt` freshness and optional metadata blocks, and timeline cards surface change metadata when it is present, so the graph history view preserves the richer provenance already carried by the unified-resource model instead of flattening those fields away. +The same timeline and facet-bundle reads now also accept governed `kind` and +`sourceType` filters, so drill-down history can narrow by canonical change +class while the store still owns the filtered total counts. The Connected infrastructure settings surface now also depends on a backend owned `connectedInfrastructure` projection derived from unified resources plus reporting-ignore state. That projection is now also the only v6 client diff --git a/frontend-modern/src/api/__tests__/resources.test.ts b/frontend-modern/src/api/__tests__/resources.test.ts index e828e0eac..01b83e31d 100644 --- a/frontend-modern/src/api/__tests__/resources.test.ts +++ b/frontend-modern/src/api/__tests__/resources.test.ts @@ -46,10 +46,12 @@ describe('ResourceAPI', () => { const result = await ResourceAPI.getFacetBundle('vm:42', { since: '2026-03-18T12:00:00Z', limit: 25, + kind: 'restart', + sourceType: 'platform_event', }); expect(apiFetchJSON).toHaveBeenCalledWith( - '/api/resources/vm%3A42/facets?since=2026-03-18T12%3A00%3A00.000Z&limit=25', + '/api/resources/vm%3A42/facets?since=2026-03-18T12%3A00%3A00.000Z&limit=25&kind=restart&sourceType=platform_event', { cache: 'no-store', }, @@ -82,10 +84,37 @@ describe('ResourceAPI', () => { await ResourceAPI.getTimeline('vm:42', { since: 'not-a-date', limit: -1, + kind: 'metric_anomaly', + sourceType: 'pulse_diff', }); - expect(apiFetchJSON).toHaveBeenCalledWith('/api/resources/vm%3A42/timeline', { - cache: 'no-store', + expect(apiFetchJSON).toHaveBeenCalledWith( + '/api/resources/vm%3A42/timeline?kind=metric_anomaly&sourceType=pulse_diff', + { + cache: 'no-store', + }, + ); + }); + + it('preserves timeline filters when the time window is valid', async () => { + vi.mocked(apiFetchJSON).mockResolvedValueOnce({ + resourceId: 'vm:42', + recentChanges: [], + count: 0, + } as any); + + await ResourceAPI.getTimeline('vm:42', { + since: '2026-03-18T12:00:00Z', + limit: 10, + kind: 'state_transition', + sourceType: 'platform_event', }); + + expect(apiFetchJSON).toHaveBeenCalledWith( + '/api/resources/vm%3A42/timeline?since=2026-03-18T12%3A00%3A00.000Z&limit=10&kind=state_transition&sourceType=platform_event', + { + cache: 'no-store', + }, + ); }); }); diff --git a/frontend-modern/src/api/resources.ts b/frontend-modern/src/api/resources.ts index 15ef5c492..1f5c3df79 100644 --- a/frontend-modern/src/api/resources.ts +++ b/frontend-modern/src/api/resources.ts @@ -2,6 +2,8 @@ import { apiFetchJSON } from '@/utils/apiClient'; import type { ResourceCapability, ResourceChange, + ResourceChangeKind, + ResourceChangeSourceType, ResourceFacetCounts, ResourceRelationship, } from '@/types/resource'; @@ -21,6 +23,8 @@ export interface ResourceRelationshipsResponse { export interface ResourceTimelineQueryOptions { since?: string | number | Date; limit?: number; + kind?: ResourceChangeKind; + sourceType?: ResourceChangeSourceType; } export interface ResourceTimelineResponse { @@ -52,6 +56,12 @@ const buildTimelineQuery = (options?: ResourceTimelineQueryOptions): string => { if (Number.isFinite(options?.limit ?? NaN) && (options?.limit ?? 0) > 0) { params.set('limit', String(Math.trunc(options?.limit ?? 0))); } + if (options?.kind) { + params.set('kind', options.kind); + } + if (options?.sourceType) { + params.set('sourceType', options.sourceType); + } const query = params.toString(); return query ? `?${query}` : ''; }; diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx index 343f0fcd7..310ad9abf 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx @@ -1,6 +1,10 @@ import { Show, Suspense, createMemo, For, createSignal, createEffect, createResource } from 'solid-js'; import type { Component } from 'solid-js'; -import type { Resource } from '@/types/resource'; +import type { + Resource, + ResourceChangeKind, + ResourceChangeSourceType, +} from '@/types/resource'; import { getDisplayName } from '@/types/resource'; import { formatUptime, formatRelativeTime, formatAbsoluteTime } from '@/utils/format'; import { StatusDot } from '@/components/shared/StatusDot'; @@ -75,6 +79,25 @@ const buildInfrastructureResourceHref = (resourceId: string): string | null => { const hasMetadataEntries = (value?: Record | null): boolean => Boolean(value && Object.keys(value).length > 0); +const timelineKindOptions: Array<{ label: string; value: ResourceChangeKind | '' }> = [ + { label: 'All kinds', value: '' }, + { label: 'State transition', value: 'state_transition' }, + { label: 'Restart', value: 'restart' }, + { label: 'Config update', value: 'config_update' }, + { label: 'Metric anomaly', value: 'metric_anomaly' }, + { label: 'Relationship change', value: 'relationship_change' }, + { label: 'Capability change', value: 'capability_change' }, +]; + +const timelineSourceTypeOptions: Array<{ label: string; value: ResourceChangeSourceType | '' }> = [ + { label: 'All sources', value: '' }, + { label: 'Platform event', value: 'platform_event' }, + { label: 'Pulse diff', value: 'pulse_diff' }, + { label: 'Heuristic', value: 'heuristic' }, + { label: 'User action', value: 'user_action' }, + { label: 'Agent action', value: 'agent_action' }, +]; + const DrawerContent: Component = (props) => { type DrawerTab = | 'overview' @@ -213,11 +236,38 @@ const DrawerContent: Component = (props) => { const [k8sDeploymentsPrefillNamespace, setK8sDeploymentsPrefillNamespace] = createSignal(''); const resourceFacetId = createMemo(() => props.resource.id.trim()); + const [timelineKindFilter, setTimelineKindFilter] = createSignal(''); + const [timelineSourceTypeFilter, setTimelineSourceTypeFilter] = + createSignal(''); + const resourceFacetRequest = createMemo(() => { + const id = resourceFacetId(); + return id ? { id } : null; + }); const [resourceFacets, { refetch: refetchResourceFacets }] = createResource( - resourceFacetId, - async (id) => { - if (!id) return null; - return ResourceAPI.getFacetBundle(id, { limit: 25 }); + resourceFacetRequest, + async (request) => { + if (!request?.id) return null; + return ResourceAPI.getFacetBundle(request.id, { limit: 25 }); + }, + { initialValue: null }, + ); + const timelineFacetRequest = createMemo(() => { + const id = resourceFacetId(); + if (!id) return null; + const kind = timelineKindFilter(); + const sourceType = timelineSourceTypeFilter(); + if (!kind && !sourceType) return null; + return { id, kind, sourceType }; + }); + const [timelineFacets, { refetch: refetchTimelineFacets }] = createResource( + timelineFacetRequest, + async (request) => { + if (!request) return null; + return ResourceAPI.getFacetBundle(request.id, { + limit: 25, + kind: request.kind || undefined, + sourceType: request.sourceType || undefined, + }); }, { initialValue: null }, ); @@ -304,6 +354,22 @@ const DrawerContent: Component = (props) => { const resourceFacetCounts = createMemo( () => resourceFacets()?.counts ?? props.resource.facetCounts ?? null, ); + const historyFacetBundle = createMemo(() => + timelineFacetRequest() ? timelineFacets() ?? resourceFacets() : resourceFacets(), + ); + const historyCapabilities = createMemo( + () => historyFacetBundle()?.capabilities ?? resourceCapabilities(), + ); + const historyRelationships = createMemo( + () => historyFacetBundle()?.relationships ?? resourceRelationships(), + ); + const historyTimeline = createMemo(() => historyFacetBundle()?.recentChanges ?? resourceTimeline()); + const historyFacetCounts = createMemo( + () => historyFacetBundle()?.counts ?? resourceFacetCounts(), + ); + const hasTimelineFilters = createMemo( + () => Boolean(timelineKindFilter() || timelineSourceTypeFilter()), + ); const resourceCapabilityCount = createMemo( () => resourceFacetCounts()?.capabilities ?? resourceCapabilities().length, ); @@ -314,17 +380,23 @@ const DrawerContent: Component = (props) => { () => resourceFacetCounts()?.recentChanges ?? resourceTimeline().length, ); const sortedResourceTimeline = createMemo(() => - [...resourceTimeline()].sort((left, right) => { + [...historyTimeline()].sort((left, right) => { const leftTime = Date.parse(left.observedAt || ''); const rightTime = Date.parse(right.observedAt || ''); return (Number.isFinite(rightTime) ? rightTime : 0) - (Number.isFinite(leftTime) ? leftTime : 0); }), ); const facetBundleError = createMemo(() => { - const error = resourceFacets.error; + const error = timelineFacetRequest() ? timelineFacets.error : resourceFacets.error; if (!error) return ''; return (error as Error)?.message || 'Failed to load resource facets'; }); + const refetchHistoryFacets = () => { + if (timelineFacetRequest()) { + return refetchTimelineFacets(); + } + return refetchResourceFacets(); + }; const mergedSources = createMemo(() => platformData()?.sources ?? []); const sourceStatus = createMemo(() => platformData()?.sourceStatus ?? {}); const sourceHealthSummary = createMemo(() => { @@ -1339,11 +1411,72 @@ const DrawerContent: Component = (props) => {
Resource History
- - {resourceFacets.loading ? 'Refreshing facet data...' : 'Facet data loaded'} - +
+
+ {timelineFacetRequest() + ? timelineFacets.loading + ? 'Refreshing filtered facet data...' + : 'Filtered facet data loaded' + : resourceFacets.loading + ? 'Refreshing facet data...' + : 'Facet data loaded'} +
+ +
+ History filters active +
+
+
+
+ + +
+ + +
+ +
+
+
@@ -1351,7 +1484,7 @@ const DrawerContent: Component = (props) => { @@ -1363,19 +1496,19 @@ const DrawerContent: Component = (props) => {
Capabilities
- {resourceCapabilityCount()} + {historyFacetCounts()?.capabilities ?? resourceCapabilityCount()}
Relationships
- {resourceRelationshipCount()} + {historyFacetCounts()?.relationships ?? resourceRelationshipCount()}
Timeline Events
- {resourceTimelineCount()} + {historyFacetCounts()?.recentChanges ?? resourceTimelineCount()}
@@ -1386,7 +1519,7 @@ const DrawerContent: Component = (props) => { Capabilities
0} + when={historyCapabilities().length > 0} fallback={
No capability records are available for this resource yet. @@ -1394,7 +1527,7 @@ const DrawerContent: Component = (props) => { } >
- + {(capability) => (
@@ -1467,7 +1600,7 @@ const DrawerContent: Component = (props) => { Relationships
0} + when={historyRelationships().length > 0} fallback={
No relationship edges are available for this resource yet. @@ -1475,7 +1608,7 @@ const DrawerContent: Component = (props) => { } >
- + {(relationship) => (
diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx index be90219b5..1cb793217 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx @@ -227,4 +227,135 @@ describe('ResourceDetailDrawer history tab', () => { expect(panel.getByText(/"hypervisor": "pve-1"/)).toBeInTheDocument(); expect(panel.getByText(/"ticket": "INC-1234"/)).toBeInTheDocument(); }); + + it('filters timeline entries by kind and source type', async () => { + const unfilteredFacetBundle = { + capabilities: [ + { + name: 'restart', + type: 'common', + description: 'Restart the resource safely.', + minimumApprovalLevel: 'admin', + }, + ], + relationships: [ + { + sourceId: 'node:pve-1', + targetId: 'vm:42', + type: 'runs_on', + confidence: 1, + active: true, + discoverer: 'proxmox_adapter', + observedAt: '2026-03-18T12:00:00Z', + lastSeenAt: '2026-03-18T12:05:00Z', + metadata: { + cluster: 'pve-prod', + source: 'live', + }, + }, + ], + recentChanges: [ + { + id: 'change-1', + observedAt: '2026-03-18T12:06:00Z', + occurredAt: '2026-03-18T12:04:00Z', + resourceId: 'vm:42', + kind: 'restart', + sourceType: 'platform_event', + sourceAdapter: 'proxmox_adapter', + confidence: 'high', + actor: 'agent:oncall-helper', + relatedResources: ['node:pve-1'], + reason: 'Routine restart requested', + metadata: { + ticket: 'INC-1234', + }, + }, + { + id: 'change-2', + observedAt: '2026-03-18T12:02:00Z', + resourceId: 'vm:42', + kind: 'metric_anomaly', + sourceType: 'pulse_diff', + sourceAdapter: 'proxmox_adapter', + confidence: 'medium', + reason: 'CPU spike detected', + }, + ], + counts: { + capabilities: 1, + relationships: 1, + recentChanges: 2, + }, + }; + const filteredFacetBundle = { + capabilities: [ + { + name: 'restart', + type: 'common', + description: 'Restart the resource safely.', + minimumApprovalLevel: 'admin', + }, + ], + relationships: unfilteredFacetBundle.relationships, + recentChanges: [ + { + id: 'change-1', + observedAt: '2026-03-18T12:06:00Z', + occurredAt: '2026-03-18T12:04:00Z', + resourceId: 'vm:42', + kind: 'restart', + sourceType: 'platform_event', + sourceAdapter: 'proxmox_adapter', + confidence: 'high', + actor: 'agent:oncall-helper', + relatedResources: ['node:pve-1'], + reason: 'Routine restart requested', + metadata: { + ticket: 'INC-1234', + }, + }, + ], + counts: { + capabilities: 1, + relationships: 1, + recentChanges: 1, + }, + }; + facetBundleMock.getFacetBundle + .mockResolvedValueOnce(unfilteredFacetBundle) + .mockResolvedValueOnce(filteredFacetBundle) + .mockResolvedValueOnce(filteredFacetBundle); + + const resource = baseResource({ + id: 'vm:42', + type: 'vm', + name: 'vm-42', + displayName: 'VM 42', + platformId: 'vm-42', + platformType: 'proxmox-pve', + platformData: { sources: ['proxmox'] }, + }); + + render(() => ); + + fireEvent.click(screen.getByRole('button', { name: 'History' })); + + await screen.findByText('Resource History'); + const historyPanel = screen.getByTestId('resource-history-tab'); + const panel = within(historyPanel); + expect(await panel.findByText('restart')).toBeInTheDocument(); + expect(panel.getByText('CPU spike detected')).toBeInTheDocument(); + + fireEvent.change(panel.getByLabelText('Change kind'), { + target: { value: 'restart' }, + }); + fireEvent.change(panel.getByLabelText('Source type'), { + target: { value: 'platform_event' }, + }); + + expect(await panel.findByText('Filtered facet data loaded')).toBeInTheDocument(); + expect(await panel.findByText('Routine restart requested')).toBeInTheDocument(); + expect(panel.queryByText('CPU spike detected')).toBeNull(); + }); }); diff --git a/internal/api/resources.go b/internal/api/resources.go index 17795c5d7..403b2d91e 100644 --- a/internal/api/resources.go +++ b/internal/api/resources.go @@ -3,8 +3,10 @@ package api import ( "encoding/json" "errors" + "fmt" "io" "net/http" + "net/url" "sort" "strconv" "strings" @@ -287,6 +289,73 @@ type resourceFacetBundleResponse struct { Counts resourceFacetCountsResponse `json:"counts"` } +func parseResourceChangeFilters(values url.Values) (unified.ResourceChangeFilters, error) { + var filters unified.ResourceChangeFilters + if kinds, err := parseResourceChangeKinds(values["kind"]); err != nil { + return unified.ResourceChangeFilters{}, err + } else { + filters.Kinds = kinds + } + if sourceTypes, err := parseResourceChangeSourceTypes(values["sourceType"]); err != nil { + return unified.ResourceChangeFilters{}, err + } else { + filters.SourceTypes = sourceTypes + } + return filters, nil +} + +func parseResourceChangeKinds(values []string) ([]unified.ChangeKind, error) { + parsed := make([]unified.ChangeKind, 0, len(values)) + for _, value := range values { + for _, token := range strings.Split(value, ",") { + switch normalized := strings.TrimSpace(strings.ToLower(token)); normalized { + case "": + continue + case string(unified.ChangeStateTransition): + parsed = append(parsed, unified.ChangeStateTransition) + case string(unified.ChangeRestart): + parsed = append(parsed, unified.ChangeRestart) + case string(unified.ChangeConfigUpdate): + parsed = append(parsed, unified.ChangeConfigUpdate) + case string(unified.ChangeAnomaly): + parsed = append(parsed, unified.ChangeAnomaly) + case string(unified.ChangeRelationship): + parsed = append(parsed, unified.ChangeRelationship) + case string(unified.ChangeCapability): + parsed = append(parsed, unified.ChangeCapability) + default: + return nil, fmt.Errorf("invalid kind value %q", token) + } + } + } + return parsed, nil +} + +func parseResourceChangeSourceTypes(values []string) ([]unified.ChangeSourceType, error) { + parsed := make([]unified.ChangeSourceType, 0, len(values)) + for _, value := range values { + for _, token := range strings.Split(value, ",") { + switch normalized := strings.TrimSpace(strings.ToLower(token)); normalized { + case "": + continue + case string(unified.SourcePlatformEvent): + parsed = append(parsed, unified.SourcePlatformEvent) + case string(unified.SourcePulseDiff): + parsed = append(parsed, unified.SourcePulseDiff) + case string(unified.SourceHeuristic): + parsed = append(parsed, unified.SourceHeuristic) + case string(unified.SourceUserAction): + parsed = append(parsed, unified.SourceUserAction) + case string(unified.SourceAgentAction): + parsed = append(parsed, unified.SourceAgentAction) + default: + return nil, fmt.Errorf("invalid sourceType value %q", token) + } + } + } + return parsed, nil +} + // HandleResourceRoutes dispatches nested resource routes. func (h *ResourceHandlers) HandleResourceRoutes(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/facets") { @@ -380,13 +449,18 @@ func (h *ResourceHandlers) HandleGetResourceFacets(w http.ResponseWriter, r *htt } limit = parsed } + filters, err := parseResourceChangeFilters(r.URL.Query()) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } - recentChanges, err := store.GetRecentChanges(resourceID, since, limit) + recentChanges, err := store.GetRecentChangesFiltered(resourceID, since, limit, filters) if err != nil { http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) return } - changeCount, err := store.CountRecentChanges(resourceID, since) + changeCount, err := store.CountRecentChangesFiltered(resourceID, since, filters) if err != nil { http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) return @@ -607,13 +681,18 @@ func (h *ResourceHandlers) HandleGetResourceTimeline(w http.ResponseWriter, r *h } limit = parsed } + filters, err := parseResourceChangeFilters(r.URL.Query()) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } - changes, err := store.GetRecentChanges(resourceID, since, limit) + 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.CountRecentChanges(resourceID, since) + changeCount, err := store.CountRecentChangesFiltered(resourceID, since, filters) if err != nil { http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) return diff --git a/internal/api/resources_test.go b/internal/api/resources_test.go index df5ba4b5f..d133ba470 100644 --- a/internal/api/resources_test.go +++ b/internal/api/resources_test.go @@ -941,7 +941,7 @@ func TestResourceGetFacetsAndTimeline(t *testing.T) { ResourceID: "vm:42", ObservedAt: now, OccurredAt: &now, - Kind: unified.ChangeStateTransition, + Kind: unified.ChangeRestart, From: "offline", To: "online", SourceType: unified.SourcePlatformEvent, @@ -1071,6 +1071,57 @@ func TestResourceGetFacetsAndTimeline(t *testing.T) { t.Fatalf("unexpected timeline metadata: %#v", payload.RecentChanges[0].Metadata) } }) + + t.Run("filtered timeline", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:42/timeline?kind=restart&sourceType=platform_event", nil) + h.HandleResourceRoutes(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 filtered timeline: %v", err) + } + if payload.ResourceID != "vm:42" || payload.Count != 1 || len(payload.RecentChanges) != 1 { + t.Fatalf("unexpected filtered timeline payload: %#v", payload) + } + if payload.RecentChanges[0].ID != "chg-42" { + t.Fatalf("unexpected filtered timeline change: %#v", payload.RecentChanges[0]) + } + }) + + 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) + h.HandleResourceRoutes(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"` + Relationships []unified.ResourceRelationship `json:"relationships"` + Counts struct { + Capabilities int `json:"capabilities"` + Relationships int `json:"relationships"` + RecentChanges int `json:"recentChanges"` + } `json:"counts"` + } + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode filtered facets: %v", err) + } + if payload.ResourceID != "vm:42" || payload.Counts.RecentChanges != 1 || len(payload.RecentChanges) != 1 { + t.Fatalf("unexpected filtered facets payload: %#v", payload) + } + if payload.RecentChanges[0].ID != "chg-42" { + t.Fatalf("unexpected filtered facets change: %#v", payload.RecentChanges[0]) + } + }) } func containsSource(sources []unified.DataSource, target unified.DataSource) bool { diff --git a/internal/unifiedresources/changes.go b/internal/unifiedresources/changes.go index 8b92dd687..db9f1607a 100644 --- a/internal/unifiedresources/changes.go +++ b/internal/unifiedresources/changes.go @@ -46,6 +46,41 @@ const ( AdapterOpsAgent ChangeSourceAdapter = "agent:ops-helper" ) +// ResourceChangeFilters narrows the resource timeline to specific change kinds +// and source origins while preserving the canonical change record shape. +type ResourceChangeFilters struct { + Kinds []ChangeKind `json:"kinds,omitempty"` + SourceTypes []ChangeSourceType `json:"sourceTypes,omitempty"` +} + +func (filters ResourceChangeFilters) matches(change ResourceChange) bool { + if len(filters.Kinds) > 0 { + match := false + for _, kind := range filters.Kinds { + if kind == change.Kind { + match = true + break + } + } + if !match { + return false + } + } + if len(filters.SourceTypes) > 0 { + match := false + for _, sourceType := range filters.SourceTypes { + if sourceType == change.SourceType { + match = true + break + } + } + if !match { + return false + } + } + return true +} + // ResourceChange represents a deterministic point-in-time state transition, // event, or metadata change tracked by Pulse, forming the historical "Court Record". type ResourceChange struct { diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index 689d07086..4fa5e41f5 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -245,6 +245,9 @@ func TestResourceAPIExposesDedicatedFacetReads(t *testing.T) { "HandleGetResourceCapabilities", "HandleGetResourceRelationships", "HandleGetResourceTimeline", + "parseResourceChangeFilters(r.URL.Query())", + "GetRecentChangesFiltered(resourceID, since, limit, filters)", + "CountRecentChangesFiltered(resourceID, since, filters)", "strings.HasSuffix(r.URL.Path, \"/facets\")", "strings.HasSuffix(r.URL.Path, \"/capabilities\")", "strings.HasSuffix(r.URL.Path, \"/relationships\")", diff --git a/internal/unifiedresources/store.go b/internal/unifiedresources/store.go index ba6860a10..e912bc9cc 100644 --- a/internal/unifiedresources/store.go +++ b/internal/unifiedresources/store.go @@ -26,7 +26,9 @@ type ResourceStore interface { GetExclusions() ([]ResourceExclusion, error) RecordChange(change ResourceChange) error GetRecentChanges(canonicalID string, since time.Time, limit int) ([]ResourceChange, error) + GetRecentChangesFiltered(canonicalID string, since time.Time, limit int, filters ResourceChangeFilters) ([]ResourceChange, error) CountRecentChanges(canonicalID string, since time.Time) (int, error) + CountRecentChangesFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (int, error) RecordActionAudit(record ActionAuditRecord) error GetActionAudits(canonicalID string, since time.Time, limit int) ([]ActionAuditRecord, error) RecordActionLifecycleEvent(event ActionLifecycleEvent) error @@ -345,6 +347,104 @@ func (s *SQLiteResourceStore) initSchema() error { if err != nil { return fmt.Errorf("failed to initialize resource store schema: %w", err) } + if err := s.migrateResourceChangesSchema(); err != nil { + return err + } + return nil +} + +func (s *SQLiteResourceStore) migrateResourceChangesSchema() error { + columns, err := s.tableColumns("resource_changes") + if err != nil { + return err + } + + if err := s.addResourceChangesColumnIfMissing(columns, "source_type", "TEXT NOT NULL DEFAULT 'pulse_diff'"); err != nil { + return err + } + if err := s.addResourceChangesColumnIfMissing(columns, "source_adapter", "TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } + if err := s.addResourceChangesColumnIfMissing(columns, "actor", "TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } + if err := s.addResourceChangesColumnIfMissing(columns, "related_resources", "TEXT NOT NULL DEFAULT '[]'"); err != nil { + return err + } + if err := s.addResourceChangesColumnIfMissing(columns, "metadata_json", "TEXT NOT NULL DEFAULT '{}'"); err != nil { + return err + } + + if err := s.normalizeResourceChangeRows(columns); err != nil { + return err + } + return nil +} + +func (s *SQLiteResourceStore) addResourceChangesColumnIfMissing(columns map[string]struct{}, columnName, definition string) error { + if _, ok := columns[columnName]; ok { + return nil + } + if _, err := s.db.Exec("ALTER TABLE resource_changes ADD COLUMN " + columnName + " " + definition); err != nil { + return fmt.Errorf("add resource_changes.%s column: %w", columnName, err) + } + columns[columnName] = struct{}{} + return nil +} + +func (s *SQLiteResourceStore) tableColumns(tableName string) (map[string]struct{}, error) { + rows, err := s.db.Query(`PRAGMA table_info(` + tableName + `)`) + if err != nil { + return nil, fmt.Errorf("inspect %s schema: %w", tableName, err) + } + defer rows.Close() + + columns := make(map[string]struct{}) + for rows.Next() { + var ( + cid int + name string + typ string + notNull int + dflt sql.NullString + pk int + ) + if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil { + return nil, fmt.Errorf("scan %s schema: %w", tableName, err) + } + columns[name] = struct{}{} + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate %s schema: %w", tableName, err) + } + return columns, nil +} + +func (s *SQLiteResourceStore) normalizeResourceChangeRows(columns map[string]struct{}) error { + assignments := []string{ + "actor = COALESCE(NULLIF(TRIM(actor), ''), '')", + "related_resources = COALESCE(NULLIF(TRIM(related_resources), ''), '[]')", + "metadata_json = COALESCE(NULLIF(TRIM(metadata_json), ''), '{}')", + } + if _, ok := columns["source"]; ok { + assignments = append(assignments, + "source_adapter = CASE WHEN TRIM(COALESCE(source_adapter, '')) = '' THEN COALESCE(NULLIF(TRIM(source), ''), '') ELSE source_adapter END", + "source_type = CASE "+ + "WHEN TRIM(COALESCE(source_type, '')) = '' THEN "+ + "CASE WHEN lower(TRIM(COALESCE(source, ''))) IN ('platform_event', 'pulse_diff', 'heuristic', 'user_action', 'agent_action') "+ + "THEN lower(TRIM(source)) ELSE 'pulse_diff' END "+ + "ELSE lower(TRIM(source_type)) END", + ) + } else { + assignments = append(assignments, + "source_adapter = COALESCE(NULLIF(TRIM(source_adapter), ''), '')", + "source_type = CASE WHEN TRIM(COALESCE(source_type, '')) = '' THEN 'pulse_diff' ELSE lower(TRIM(source_type)) END", + ) + } + query := `UPDATE resource_changes SET ` + strings.Join(assignments, ", ") + if _, err := s.db.Exec(query); err != nil { + return fmt.Errorf("normalize resource_changes rows: %w", err) + } return nil } @@ -496,21 +596,47 @@ func (s *SQLiteResourceStore) RecordChange(change ResourceChange) error { } func (s *SQLiteResourceStore) GetRecentChanges(canonicalID string, since time.Time, limit int) ([]ResourceChange, error) { + return s.GetRecentChangesFiltered(canonicalID, since, limit, ResourceChangeFilters{}) +} + +func (s *SQLiteResourceStore) GetRecentChangesFiltered(canonicalID string, since time.Time, limit int, filters ResourceChangeFilters) ([]ResourceChange, error) { query := ` - SELECT id, canonical_id, observed_at, occurred_at, kind, from_state, to_state, source_type, source_adapter, actor, confidence, reason, related_resources, metadata_json + SELECT id, canonical_id, observed_at, occurred_at, COALESCE(kind, ''), COALESCE(from_state, ''), COALESCE(to_state, ''), COALESCE(source_type, ''), COALESCE(source_adapter, ''), COALESCE(actor, ''), COALESCE(confidence, ''), COALESCE(reason, ''), COALESCE(related_resources, ''), COALESCE(metadata_json, '') FROM resource_changes` args := []any{} + conditions := []string{} canonicalID = CanonicalResourceID(canonicalID) if canonicalID != "" { - query += ` - WHERE canonical_id = ? AND observed_at >= ?` - args = append(args, canonicalID, since) + conditions = append(conditions, "canonical_id = ?") + args = append(args, canonicalID) } else { - query += ` - WHERE observed_at >= ?` + conditions = append(conditions, "observed_at >= ?") args = append(args, since) } + if !since.IsZero() && canonicalID != "" { + conditions = append(conditions, "observed_at >= ?") + args = append(args, since) + } + if len(filters.Kinds) > 0 { + placeholders := make([]string, 0, len(filters.Kinds)) + for _, kind := range filters.Kinds { + placeholders = append(placeholders, "?") + args = append(args, string(kind)) + } + conditions = append(conditions, "kind IN ("+strings.Join(placeholders, ", ")+")") + } + if len(filters.SourceTypes) > 0 { + placeholders := make([]string, 0, len(filters.SourceTypes)) + for _, sourceType := range filters.SourceTypes { + placeholders = append(placeholders, "?") + args = append(args, string(sourceType)) + } + conditions = append(conditions, "source_type IN ("+strings.Join(placeholders, ", ")+")") + } + if len(conditions) > 0 { + query += "\n\t\tWHERE " + strings.Join(conditions, " AND ") + } query += ` ORDER BY observed_at DESC` if limit > 0 { @@ -554,13 +680,36 @@ func (s *SQLiteResourceStore) GetRecentChanges(canonicalID string, since time.Ti } func (s *SQLiteResourceStore) CountRecentChanges(canonicalID string, since time.Time) (int, error) { - query := `SELECT COUNT(*) FROM resource_changes WHERE observed_at >= ?` - args := []any{since} + return s.CountRecentChangesFiltered(canonicalID, since, ResourceChangeFilters{}) +} + +func (s *SQLiteResourceStore) CountRecentChangesFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (int, error) { + query := `SELECT COUNT(*) FROM resource_changes` + args := []any{} + conditions := []string{"observed_at >= ?"} + args = append(args, since) canonicalID = CanonicalResourceID(canonicalID) if canonicalID != "" { - query += ` AND canonical_id = ?` + conditions = append(conditions, "canonical_id = ?") args = append(args, canonicalID) } + if len(filters.Kinds) > 0 { + placeholders := make([]string, 0, len(filters.Kinds)) + for _, kind := range filters.Kinds { + placeholders = append(placeholders, "?") + args = append(args, string(kind)) + } + conditions = append(conditions, "kind IN ("+strings.Join(placeholders, ", ")+")") + } + if len(filters.SourceTypes) > 0 { + placeholders := make([]string, 0, len(filters.SourceTypes)) + for _, sourceType := range filters.SourceTypes { + placeholders = append(placeholders, "?") + args = append(args, string(sourceType)) + } + conditions = append(conditions, "source_type IN ("+strings.Join(placeholders, ", ")+")") + } + query += ` WHERE ` + strings.Join(conditions, " AND ") s.mu.Lock() defer s.mu.Unlock() @@ -875,6 +1024,10 @@ func (m *MemoryStore) RecordChange(change ResourceChange) error { } func (m *MemoryStore) GetRecentChanges(canonicalID string, since time.Time, limit int) ([]ResourceChange, error) { + return m.GetRecentChangesFiltered(canonicalID, since, limit, ResourceChangeFilters{}) +} + +func (m *MemoryStore) GetRecentChangesFiltered(canonicalID string, since time.Time, limit int, filters ResourceChangeFilters) ([]ResourceChange, error) { m.mu.RLock() defer m.mu.RUnlock() canonicalID = CanonicalResourceID(canonicalID) @@ -887,6 +1040,9 @@ func (m *MemoryStore) GetRecentChanges(canonicalID string, since time.Time, limi if !since.IsZero() && change.ObservedAt.Before(since) { continue } + if !filters.matches(change) { + continue + } out = append(out, change) if limit > 0 && len(out) >= limit { break @@ -896,6 +1052,10 @@ func (m *MemoryStore) GetRecentChanges(canonicalID string, since time.Time, limi } func (m *MemoryStore) CountRecentChanges(canonicalID string, since time.Time) (int, error) { + return m.CountRecentChangesFiltered(canonicalID, since, ResourceChangeFilters{}) +} + +func (m *MemoryStore) CountRecentChangesFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (int, error) { m.mu.RLock() defer m.mu.RUnlock() canonicalID = CanonicalResourceID(canonicalID) @@ -907,6 +1067,9 @@ func (m *MemoryStore) CountRecentChanges(canonicalID string, since time.Time) (i if !since.IsZero() && change.ObservedAt.Before(since) { continue } + if !filters.matches(change) { + continue + } count++ } return count, nil diff --git a/internal/unifiedresources/store_test.go b/internal/unifiedresources/store_test.go index 4faf1b1db..c769a3da0 100644 --- a/internal/unifiedresources/store_test.go +++ b/internal/unifiedresources/store_test.go @@ -126,6 +126,90 @@ func TestNewSQLiteResourceStore_MigratesLegacyStore(t *testing.T) { } } +func TestNewSQLiteResourceStore_MigratesLegacyResourceChangesTable(t *testing.T) { + dataDir := t.TempDir() + legacyPath := filepath.Join(dataDir, "resources", resourceDBFileName) + if err := os.MkdirAll(filepath.Dir(legacyPath), 0o700); err != nil { + t.Fatalf("MkdirAll(%q) failed: %v", filepath.Dir(legacyPath), err) + } + + db, err := sql.Open("sqlite", legacyPath) + if err != nil { + t.Fatalf("sql.Open(%q) failed: %v", legacyPath, err) + } + if _, err := db.Exec(` + CREATE TABLE resource_changes ( + id TEXT PRIMARY KEY, + canonical_id TEXT NOT NULL, + observed_at DATETIME NOT NULL, + occurred_at DATETIME, + kind TEXT NOT NULL, + from_state TEXT, + to_state TEXT, + source TEXT, + confidence TEXT NOT NULL, + reason TEXT + ) + `); err != nil { + _ = db.Close() + t.Fatalf("create legacy resource_changes table failed: %v", err) + } + if _, err := db.Exec(` + INSERT INTO resource_changes ( + id, canonical_id, observed_at, occurred_at, kind, from_state, to_state, source, confidence, reason + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "chg-legacy", "vm:legacy", time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC), nil, string(ChangeStateTransition), "offline", "online", "proxmox", string(ConfidenceHigh), "legacy row"); err != nil { + _ = db.Close() + t.Fatalf("insert legacy resource change failed: %v", err) + } + if err := db.Close(); err != nil { + t.Fatalf("close legacy db failed: %v", err) + } + + store, err := NewSQLiteResourceStore(dataDir, defaultOrgID) + if err != nil { + t.Fatalf("NewSQLiteResourceStore returned error: %v", err) + } + defer store.Close() + + results, err := store.GetRecentChanges("vm:legacy", time.Time{}, 10) + if err != nil { + t.Fatalf("GetRecentChanges on migrated legacy table returned error: %v", err) + } + if len(results) != 1 { + t.Fatalf("GetRecentChanges on migrated legacy table returned %d rows, want 1", len(results)) + } + if results[0].ID != "chg-legacy" { + t.Fatalf("unexpected legacy row after migration: %+v", results[0]) + } + if results[0].SourceType != SourcePulseDiff { + t.Fatalf("legacy source type = %q, want %q", results[0].SourceType, SourcePulseDiff) + } + if results[0].SourceAdapter != ChangeSourceAdapter("proxmox") { + t.Fatalf("legacy source adapter = %q, want proxmox", results[0].SourceAdapter) + } + + if err := store.RecordChange(ResourceChange{ + ID: "chg-new", + ResourceID: "vm:legacy", + ObservedAt: time.Date(2026, 3, 18, 13, 0, 0, 0, time.UTC), + Kind: ChangeRestart, + SourceType: SourcePlatformEvent, + SourceAdapter: AdapterProxmox, + Confidence: ConfidenceHigh, + Reason: "post-migration write", + }); err != nil { + t.Fatalf("RecordChange after migration failed: %v", err) + } + results, err = store.GetRecentChanges("vm:legacy", time.Time{}, 10) + if err != nil { + t.Fatalf("GetRecentChanges after migration write returned error: %v", err) + } + if len(results) != 2 { + t.Fatalf("GetRecentChanges after migration write returned %d rows, want 2", len(results)) + } +} + func newTestStore(t *testing.T) *SQLiteResourceStore { t.Helper() dir := t.TempDir() @@ -328,6 +412,82 @@ func TestCountRecentChanges_RespectsFilters(t *testing.T) { if allCount != 2 { t.Fatalf("CountRecentChanges all = %d, want 2", allCount) } + + filteredCount, err := store.CountRecentChangesFiltered("vm:1", base.Add(-35*time.Minute), ResourceChangeFilters{ + Kinds: []ChangeKind{ChangeAnomaly, ChangeRelationship}, + }) + if err != nil { + t.Fatalf("CountRecentChangesFiltered kinds: %v", err) + } + if filteredCount != 2 { + t.Fatalf("CountRecentChangesFiltered kinds = %d, want 2", filteredCount) + } + + sourceFilteredCount, err := store.CountRecentChangesFiltered("", base.Add(-25*time.Minute), ResourceChangeFilters{ + SourceTypes: []ChangeSourceType{SourcePulseDiff}, + }) + if err != nil { + t.Fatalf("CountRecentChangesFiltered source types: %v", err) + } + if sourceFilteredCount != 3 { + t.Fatalf("CountRecentChangesFiltered source types = %d, want 3", sourceFilteredCount) + } +} + +func TestGetRecentChanges_RespectsFilters(t *testing.T) { + store := newTestStore(t) + base := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC) + changes := []ResourceChange{ + { + ID: "chg-1", + ResourceID: "vm:1", + ObservedAt: base.Add(-30 * time.Minute), + Kind: ChangeStateTransition, + SourceType: SourcePlatformEvent, + Confidence: ConfidenceHigh, + }, + { + ID: "chg-2", + ResourceID: "vm:1", + ObservedAt: base.Add(-20 * time.Minute), + Kind: ChangeAnomaly, + SourceType: SourcePulseDiff, + Confidence: ConfidenceMedium, + }, + { + ID: "chg-3", + ResourceID: "vm:1", + ObservedAt: base.Add(-10 * time.Minute), + Kind: ChangeRelationship, + SourceType: SourcePulseDiff, + Confidence: ConfidenceLow, + }, + } + for _, change := range changes { + if err := store.RecordChange(change); err != nil { + t.Fatalf("RecordChange(%s): %v", change.ID, err) + } + } + + results, err := store.GetRecentChangesFiltered("vm:1", base.Add(-35*time.Minute), 10, ResourceChangeFilters{ + Kinds: []ChangeKind{ChangeRelationship}, + }) + if err != nil { + t.Fatalf("GetRecentChangesFiltered kinds: %v", err) + } + if len(results) != 1 || results[0].ID != "chg-3" { + t.Fatalf("GetRecentChangesFiltered kinds = %#v, want chg-3", results) + } + + sourceResults, err := store.GetRecentChangesFiltered("vm:1", base.Add(-25*time.Minute), 10, ResourceChangeFilters{ + SourceTypes: []ChangeSourceType{SourcePulseDiff}, + }) + if err != nil { + t.Fatalf("GetRecentChangesFiltered source types: %v", err) + } + if len(sourceResults) != 2 || sourceResults[0].ID != "chg-3" || sourceResults[1].ID != "chg-2" { + t.Fatalf("GetRecentChangesFiltered source types = %#v, want chg-3 then chg-2", sourceResults) + } } func TestActionAuditRecord_RoundTrip(t *testing.T) {