mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 17:19:57 +00:00
Harden unified resource timeline filters
This commit is contained in:
parent
33b169334c
commit
9c43a48ff0
14 changed files with 844 additions and 35 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}` : '';
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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\")",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue