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