Harden unified resource timeline filters

This commit is contained in:
rcourtman 2026-03-18 20:29:30 +00:00
parent 33b169334c
commit 9c43a48ff0
14 changed files with 844 additions and 35 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}` : '';
};

View file

@ -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<string, unknown> | 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<ResourceDetailDrawerProps> = (props) => {
type DrawerTab =
| 'overview'
@ -213,11 +236,38 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
const [k8sDeploymentsPrefillNamespace, setK8sDeploymentsPrefillNamespace] = createSignal('');
const resourceFacetId = createMemo(() => props.resource.id.trim());
const [timelineKindFilter, setTimelineKindFilter] = createSignal<ResourceChangeKind | ''>('');
const [timelineSourceTypeFilter, setTimelineSourceTypeFilter] =
createSignal<ResourceChangeSourceType | ''>('');
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<ResourceDetailDrawerProps> = (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<ResourceDetailDrawerProps> = (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<ResourceDetailDrawerProps> = (props) => {
<div class="text-[11px] font-medium uppercase tracking-wide text-base-content">
Resource History
</div>
<span class="text-[10px] text-muted">
{resourceFacets.loading ? 'Refreshing facet data...' : 'Facet data loaded'}
</span>
<div class="text-right text-[10px] text-muted">
<div>
{timelineFacetRequest()
? timelineFacets.loading
? 'Refreshing filtered facet data...'
: 'Filtered facet data loaded'
: resourceFacets.loading
? 'Refreshing facet data...'
: 'Facet data loaded'}
</div>
<Show when={hasTimelineFilters()}>
<div class="mt-0.5 text-blue-700 dark:text-blue-300">
History filters active
</div>
</Show>
</div>
</div>
<div class="mt-3 grid gap-2 sm:grid-cols-2">
<label class="space-y-1 text-[10px]">
<span class="text-muted">Change kind</span>
<select
class="w-full rounded border border-border bg-base px-2 py-1 text-[11px] text-base-content"
value={timelineKindFilter()}
onChange={(event) =>
setTimelineKindFilter((event.currentTarget.value || '') as ResourceChangeKind | '')
}
>
<For each={timelineKindOptions}>
{(option) => <option value={option.value}>{option.label}</option>}
</For>
</select>
</label>
<label class="space-y-1 text-[10px]">
<span class="text-muted">Source type</span>
<select
class="w-full rounded border border-border bg-base px-2 py-1 text-[11px] text-base-content"
value={timelineSourceTypeFilter()}
onChange={(event) =>
setTimelineSourceTypeFilter(
(event.currentTarget.value || '') as ResourceChangeSourceType | '',
)
}
>
<For each={timelineSourceTypeOptions}>
{(option) => <option value={option.value}>{option.label}</option>}
</For>
</select>
</label>
</div>
<Show when={hasTimelineFilters()}>
<div class="mt-2 flex justify-end">
<button
type="button"
class="rounded-md border border-border bg-surface-hover px-2.5 py-1 text-[10px] font-semibold text-base-content hover:bg-surface"
onClick={() => {
setTimelineKindFilter('');
setTimelineSourceTypeFilter('');
}}
>
Clear filters
</button>
</div>
</Show>
<Show when={facetBundleError()}>
<div class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[10px] text-amber-700 dark:border-amber-700 dark:bg-amber-900 dark:text-amber-200">
<div class="flex items-start justify-between gap-2">
@ -1351,7 +1484,7 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
<button
type="button"
class="shrink-0 font-medium text-amber-700 underline dark:text-amber-200"
onClick={() => refetchResourceFacets()}
onClick={() => refetchHistoryFacets()}
>
Retry
</button>
@ -1363,19 +1496,19 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
<div class="rounded border border-border bg-surface-hover px-2 py-1.5">
<div class="text-[10px] text-muted">Capabilities</div>
<div class="text-sm font-semibold text-base-content">
{resourceCapabilityCount()}
{historyFacetCounts()?.capabilities ?? resourceCapabilityCount()}
</div>
</div>
<div class="rounded border border-border bg-surface-hover px-2 py-1.5">
<div class="text-[10px] text-muted">Relationships</div>
<div class="text-sm font-semibold text-base-content">
{resourceRelationshipCount()}
{historyFacetCounts()?.relationships ?? resourceRelationshipCount()}
</div>
</div>
<div class="rounded border border-border bg-surface-hover px-2 py-1.5">
<div class="text-[10px] text-muted">Timeline Events</div>
<div class="text-sm font-semibold text-base-content">
{resourceTimelineCount()}
{historyFacetCounts()?.recentChanges ?? resourceTimelineCount()}
</div>
</div>
</div>
@ -1386,7 +1519,7 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
Capabilities
</div>
<Show
when={resourceCapabilities().length > 0}
when={historyCapabilities().length > 0}
fallback={
<div class="rounded border border-dashed border-border bg-surface-hover px-2 py-2 text-[10px] text-muted">
No capability records are available for this resource yet.
@ -1394,7 +1527,7 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
}
>
<div class="space-y-2">
<For each={resourceCapabilities()}>
<For each={historyCapabilities()}>
{(capability) => (
<details class="rounded border border-border bg-surface-hover px-2 py-1.5">
<summary class="flex cursor-pointer list-none items-center justify-between gap-2 text-[10px] font-medium text-base-content">
@ -1467,7 +1600,7 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
Relationships
</div>
<Show
when={resourceRelationships().length > 0}
when={historyRelationships().length > 0}
fallback={
<div class="rounded border border-dashed border-border bg-surface-hover px-2 py-2 text-[10px] text-muted">
No relationship edges are available for this resource yet.
@ -1475,7 +1608,7 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
}
>
<div class="space-y-2">
<For each={resourceRelationships()}>
<For each={historyRelationships()}>
{(relationship) => (
<div class="rounded border border-border bg-surface-hover px-2 py-1.5 text-[10px]">
<div class="flex items-center justify-between gap-2">

View file

@ -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(() => <ResourceDetailDrawer resource={resource} />);
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();
});
});

View file

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

View file

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

View file

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

View file

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

View file

@ -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, &notNull, &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

View file

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