Scope remaining provider inventory hydration

This commit is contained in:
Pulse Test
2026-08-30 00:41:04 +01:00
parent 5b2e377843
commit 5d3571dbe8
16 changed files with 296 additions and 67 deletions
+1
View File
@@ -100,6 +100,7 @@ Returns the unified resource list with pagination + aggregations. Requires `moni
Query params:
- `type`: comma-separated list (e.g., `agent`, `vm`, `system-container`, `container`, `docker-service`, `storage`, `pbs`, `pmg`, `k8s-cluster`, `k8s-node`, `pod`, `k8s-deployment`, `physical_disk`, `ceph`)
- `source`: comma-separated list (e.g., `proxmox`, `agent`, `docker`, `pbs`, `pmg`, `kubernetes`)
- `excludeSource`: comma-separated list of sources that must not be present on a matching resource
- `status`: comma-separated list (`online`, `offline`, `warning`, `unknown`)
- `parent`: parent resource ID
- `cluster`: cluster name
@@ -4244,6 +4244,17 @@ auto-register mutation boundary.
## Current State
### Unified resource source exclusion is canonical query policy
`GET /api/resources` accepts `excludeSource` as a comma-separated negative
source-membership filter alongside the inclusive `source` filter. A resource
is rejected when any canonical source matches the exclusion set, which lets a
consumer retain additive standalone facets such as Agent plus Docker while
excluding provider-owned Agent merges before pagination and serialization.
The filter uses the same normalized source vocabulary and VMware aliases as
`source`; `TestResourceListFiltersExcludeAnyMatchingSource` pins the combined
inclusive and exclusive behavior.
### Telemetry preview is the canonical schema-v14 alert-quality payload
The system settings telemetry preview continues to return the exact `Ping`
@@ -1397,6 +1397,14 @@ local WebSocket projection boundaries. Focused page contracts must pin the
exact query ownership, and browser verification must show only the active
route's resource request before first paint at desktop and phone widths.
TrueNAS likewise queries only `source=truenas`; the merged appliance source
contract preserves agent enrichment without downloading unrelated agents.
Its recovery-point inventory is inactive outside the Protection route, while
the Protection tab remains discoverable from the presence of a TrueNAS system.
Standalone Machines uses the resource API's inclusive Agent/availability
scope plus provider-source exclusions so provider-owned merged agents are
removed before paging, payload construction, and browser-side projection.
### Command-session liveness lookup stays bounded and in-memory
The connections ledger's command-channel liveness check
@@ -2403,6 +2403,16 @@ vdev layout is reported` in
## Current State
### TrueNAS protection inventory hydrates only on its owning route
The TrueNAS Protection tab is a first-class workflow once a TrueNAS system is
present, so route discovery no longer depends on preloading recovery points.
`TrueNASPageSurface.tsx` requests the platform-scoped recovery inventory only
when `/truenas/protection` is active; Overview, Storage, Services, Apps, VMs,
and Shares do not compete with that independent request during first paint.
The surface contract pins both the inactive null query and the direct-route
query parameters.
### Fresh agent command policy preserves the existing recovery authority boundary
New self-hosted install-command tokens project their explicit command-policy
@@ -2061,6 +2061,12 @@ Proxmox route queries likewise declare their exact PVE, PBS, PMG, or agent
source boundary. Backups extend the source-scoped Overview guest snapshot with
a PBS-only snapshot, so a route transition composes canonical resources rather
than repeating the guest enumeration under another cache key.
TrueNAS follows the same rule: a merged agent-backed appliance retains its
`truenas` source, so the page queries that provider alone instead of admitting
the whole Agent estate. The standalone Machines owner combines the inclusive
Agent and availability sources with canonical provider exclusions; the shared
WebSocket projection applies those exclusions on every snapshot and delta so
REST filtering cannot be undone by a later realtime update.
### Agent libvirt domains use a provider-neutral VM facet
+21 -20
View File
@@ -1,21 +1,24 @@
{
"version": 1,
"base_sha": "28be70071e8a7c2d0b9663f5d2eab5f5ba2f6528",
"verified_at": "2026-08-29T23:37:43Z",
"base_sha": "5b2e3778434891d9bfef7ce47b5d1e52e0b07162",
"verified_at": "2026-08-29T23:39:56Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/api/agentDiagnostics.ts",
"frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx",
"frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts"
"frontend-modern/src/features/standalone/StandalonePageSurface.tsx",
"frontend-modern/src/features/truenas/TrueNASPageSurface.tsx",
"frontend-modern/src/features/truenas/truenasPageModel.ts",
"frontend-modern/src/hooks/useUnifiedResources.ts"
],
"content_sha256": {
"frontend-modern/src/api/agentDiagnostics.ts": "1e3c207615fd27fedf811b40830789f779b80b570085c54970cfb277f54eb07f",
"frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx": "0053b3fa0be0fbf463a6569c4fef4d977401647fe7c717a27543eb023a69e8ff",
"frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts": "5157fd513d3d149fdd844e0e807d0d73879661ab165cf2850efa8286a888ecbf"
"frontend-modern/src/features/standalone/StandalonePageSurface.tsx": "5e05d2b068ccdcf0b00965ee94cf428549687c099a808b0d46843c8a612d663a",
"frontend-modern/src/features/truenas/TrueNASPageSurface.tsx": "18f4fd22154a87184f7fc8e0aac6343681abdfcbec08bbb775773641fa6130d9",
"frontend-modern/src/features/truenas/truenasPageModel.ts": "505265ffa886d1ad615beca19a62c622c4ddadc6027148d50000bd63b0a71070",
"frontend-modern/src/hooks/useUnifiedResources.ts": "a20ab17f175fbf747b0be7c8ec361e8ddb99ba34466895f9a9f9df54c02ab824"
},
"routes": [
"/settings/infrastructure/agent-doctor",
"/settings/infrastructure/agent-doctor?agents=host-node-mock-cluster-4-pve22"
"/truenas/overview",
"/truenas/protection",
"/standalone/machines"
],
"viewports": [
{
@@ -28,17 +31,15 @@
}
],
"states": [
"full fleet summary and expanded legacy Linux agent with explicit safe-profile inspect and apply guidance",
"eligible monitoring-only collector with typed-helper configuration and no action-runner credential or session",
"one-time host-bound action-runner credential reveal with private token prompt and separate runner installer handoff",
"credential-cleared page-session state with repeat issuance disabled",
"eligible safe collector at desktop and narrow viewport widths"
"populated TrueNAS Overview with provider-scoped resources and no recovery request",
"populated TrueNAS Overview at narrow width with no document overflow",
"TrueNAS Protection direct route with lazy platform recovery inventory",
"populated standalone Machines inventory with provider-owned agents excluded"
],
"interactions": [
"expanded a legacy Linux agent and copied the standalone safe-profile inspection command",
"issued an isolated non-persistent action-runner credential fixture and verified the normalized host identity, closed runtime role, and typed capability before reveal",
"verified the revealed secret was absent from the private token-file command, installer command, URL, and diagnostic report",
"cleared the one-time credential and verified the secret disappeared while repeat issuance stayed disabled for the page session",
"verified the 390 by 844 layout had no document overflow, checked the rendered pixels, reset the viewport override, and removed the temporary request fixture"
"captured and asserted the exact TrueNAS resource request while confirming no recovery inventory on Overview",
"opened TrueNAS Protection and confirmed the recovery request starts only on its owning route",
"captured and asserted the standalone Agent and availability request with Proxmox, Kubernetes, TrueNAS, and VMware exclusions",
"inspected desktop and 390 by 844 screenshots for populated rows, coherent layout, and horizontal overflow"
]
}
@@ -43,7 +43,13 @@ import {
type StandalonePostureSummary,
} from './standalonePageModel';
const STANDALONE_RESOURCE_QUERY = 'type=agent,network-endpoint';
// Machines owns standalone Pulse Agent hosts plus standalone availability
// targets. Provider-owned agents are excluded at the API boundary so this
// surface does not hydrate the rest of the infrastructure estate and discard
// it again in buildStandalonePageModel. Docker remains additive here because
// a standalone Pulse Agent host can also contribute a Docker facet.
const STANDALONE_RESOURCE_QUERY =
'source=agent,availability&excludeSource=proxmox,kubernetes,truenas,vmware&type=agent,network-endpoint';
const STANDALONE_TAB_SPECS = [
{ id: 'machines', label: 'Machines', path: buildStandalonePath('machines') },
{ id: 'availability', label: 'Availability checks', path: buildStandalonePath('availability') },
@@ -174,7 +174,8 @@ describe('StandalonePageSurface', () => {
expect(mocks.useUnifiedResources).toHaveBeenCalledWith(
expect.objectContaining({
query: 'type=agent,network-endpoint',
query:
'source=agent,availability&excludeSource=proxmox,kubernetes,truenas,vmware&type=agent,network-endpoint',
}),
);
expect(screen.getByTestId('standalone-section-tabs')).toHaveAttribute(
@@ -36,11 +36,11 @@ import {
// (with `storage.topology` differentiating them) — they are not
// first-class type tokens and including them triggers a 400 from
// `/api/resources`. The page model still buckets by topology
// client-side. Keep `agent` in the source filter so a TrueNAS host that
// reports through the Pulse agent can still appear as the top-level appliance
// while native TrueNAS API inventory remains the primary source.
// client-side. Canonical TrueNAS systems retain `truenas` in their merged
// source set even when a Pulse agent enriches the same appliance, so the page
// can stay provider-scoped without hydrating every agent in the estate.
const TRUENAS_RESOURCE_QUERY =
'source=truenas,agent&type=agent,vm,app-container,network-share,storage,physical_disk';
'source=truenas&type=agent,vm,app-container,network-share,storage,physical_disk';
const TRUENAS_PLATFORM_FILTER = 'truenas';
const VALID_TABS = new Set<TrueNASPageTabId>(TRUENAS_TAB_SPECS.map((tab) => tab.id));
@@ -60,7 +60,7 @@ export function TrueNASPageSurface() {
});
const model = createMemo(() => buildTrueNASPageModel(resources()));
const protection = useRecoveryPoints(() =>
model().resources.length > 0
requestedTab() === 'protection' && model().systems.length > 0
? {
platform: TRUENAS_PLATFORM_FILTER,
page: 1,
@@ -68,11 +68,10 @@ export function TrueNASPageSurface() {
}
: null,
);
const hasProtectionInventory = createMemo(
() => protection.meta().total > 0 || protection.points().length > 0,
);
const tabs = createMemo(() =>
getTrueNASPageTabSpecs(model(), { hasProtectionInventory: hasProtectionInventory() }),
getTrueNASPageTabSpecs(model(), {
hasProtectionInventory: protection.meta().total > 0 || protection.points().length > 0,
}),
);
const activeTab = createMemo<TrueNASPageTabId>(() =>
tabs().some((tab) => tab.id === requestedTab()) ? requestedTab() : 'overview',
@@ -149,7 +149,18 @@ describe('TrueNASPageSurface contract', () => {
render(() => <TrueNASPageSurface />);
expect(mockUseUnifiedResources).toHaveBeenCalledWith(
expect.objectContaining({
query: 'source=truenas&type=agent,vm,app-container,network-share,storage,physical_disk',
}),
);
const recoveryOptions = mockUseRecoveryPoints.mock.calls.at(-1)?.[0] as () => unknown;
expect(recoveryOptions()).toBeNull();
expect(screen.getByTestId('platform-section-tabs')).toHaveAttribute('data-active', 'overview');
expect(screen.getByTestId('platform-section-tabs')).toHaveAttribute(
'data-tabs',
'overview,protection',
);
expect(screen.getByTestId('systems-table')).toHaveAttribute('data-rows', '1');
const notice = screen.getByTestId('platform-outdated-agent-notice');
expect(notice).toHaveTextContent('truenas-scale is running an older Pulse agent (v5.1.34).');
@@ -161,4 +172,24 @@ describe('TrueNASPageSurface contract', () => {
'/settings/infrastructure/agent-doctor?agents=agent%3Aagent-truenas-scale',
);
});
it('loads recovery inventory only on the Protection route', () => {
mockPathname.mockReturnValue('/truenas/protection');
setResources([
makeResource({
id: 'agent:truenas-scale',
type: 'agent',
}),
]);
render(() => <TrueNASPageSurface />);
const recoveryOptions = mockUseRecoveryPoints.mock.calls.at(-1)?.[0] as () => unknown;
expect(recoveryOptions()).toEqual({ platform: 'truenas', page: 1, limit: 200 });
expect(screen.getByTestId('platform-section-tabs')).toHaveAttribute(
'data-active',
'protection',
);
expect(screen.getByTestId('protection-table')).toBeInTheDocument();
});
});
@@ -83,7 +83,10 @@ describe('truenasPageModel', () => {
}),
]);
expect(getTrueNASPageTabSpecs(systemOnlyModel).map((tab) => tab.id)).toEqual(['overview']);
expect(getTrueNASPageTabSpecs(systemOnlyModel).map((tab) => tab.id)).toEqual([
'overview',
'protection',
]);
expect(
getTrueNASPageTabSpecs(inventoryModel, { hasProtectionInventory: true }).map((tab) => tab.id),
).toEqual(['overview', 'storage', 'services', 'apps', 'vms', 'shares', 'protection']);
@@ -196,7 +196,11 @@ const hasTrueNASTabInventory = (
case 'shares':
return model.shares.length > 0;
case 'protection':
return Boolean(options.hasProtectionInventory);
// Protection is a first-class TrueNAS workflow. Keep the route visible
// once a system exists so the page can defer the recovery inventory
// request until the operator opens it without creating a discovery
// request on every other TrueNAS tab.
return model.systems.length > 0 || Boolean(options.hasProtectionInventory);
}
};
@@ -976,6 +976,94 @@ describe('useUnifiedResources', () => {
dispose();
});
it('applies inclusive and exclusive source filters to websocket hydration and updates', async () => {
const standaloneAgent = createWsResource({
id: 'standalone-agent',
name: 'standalone-agent',
displayName: 'standalone-agent',
platformId: 'standalone-agent',
sources: ['agent', 'docker'],
discoveryTarget: {
resourceType: 'agent',
agentId: 'standalone-agent',
resourceId: 'standalone-agent',
},
});
const providerAgent = createWsResource({
id: 'provider-agent',
name: 'provider-agent',
displayName: 'provider-agent',
platformId: 'provider-agent',
type: 'vm',
sources: ['agent', 'proxmox'],
discoveryTarget: {
resourceType: 'agent',
agentId: 'provider-agent',
resourceId: 'provider-agent',
},
});
const availabilityTarget = createWsResource({
id: 'availability-target',
name: 'availability-target',
displayName: 'availability-target',
type: 'network-endpoint',
platformType: 'availability',
sources: ['availability'],
});
setWsState('resources', [standaloneAgent, providerAgent, availabilityTarget]);
setWsResourceChange({ version: 1, changedIds: null });
let dispose = () => {};
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;
createRoot((d) => {
dispose = d;
result = useUnifiedResources({
query:
'source=agent,availability&excludeSource=proxmox,kubernetes,truenas,vmware&type=agent,vm,network-endpoint',
cacheKey: 'standalone-excluded-source-projection',
initialHydration: 'prefer-ws',
});
});
await waitForValue(() => result!.resources().length, 2);
expect(result!.resources().map((resource) => resource.id)).toEqual([
'standalone-agent',
'availability-target',
]);
expect(apiFetchMock).not.toHaveBeenCalled();
batch(() => {
setWsState('resources', [
standaloneAgent,
providerAgent,
availabilityTarget,
createWsResource({
id: 'truenas-agent',
name: 'truenas-agent',
displayName: 'truenas-agent',
platformId: 'truenas-agent',
type: 'vm',
sources: ['agent', 'truenas'],
discoveryTarget: {
resourceType: 'agent',
agentId: 'truenas-agent',
resourceId: 'truenas-agent',
},
}),
]);
setWsResourceChange({ version: 2, changedIds: new Set(['truenas-agent']) });
setWsState('lastUpdate', 1738843205000);
});
await flushAsync();
expect(result!.resources().map((resource) => resource.id)).toEqual([
'standalone-agent',
'availability-target',
]);
dispose();
});
it('paints from websocket before revalidating prefer-ws-then-rest screens in the background', async () => {
let resolveFetch:
| ((value: { ok: true; json: () => Promise<{ data: Array<typeof v2Resource> }> }) => void)
@@ -1133,6 +1133,7 @@ const enrichCanonicalAllResourcesCache = (
type UnifiedResourcesLocalFilter = {
types: Set<ResourceType>;
sources: Set<string>;
excludedSources: Set<string>;
};
const normalizeUnifiedResourceSource = (source: string): string => {
@@ -1168,9 +1169,10 @@ const parseUnifiedResourcesLocalFilter = (query: string): UnifiedResourcesLocalF
const params = new URLSearchParams(normalizedQuery);
const types = new Set<ResourceType>();
const sources = new Set<string>();
const excludedSources = new Set<string>();
for (const [key, value] of params.entries()) {
if (key !== 'type' && key !== 'source') {
if (key !== 'type' && key !== 'source' && key !== 'excludeSource') {
return null;
}
@@ -1185,12 +1187,18 @@ const parseUnifiedResourcesLocalFilter = (query: string): UnifiedResourcesLocalF
}
const normalizedSource = normalizeUnifiedResourceSource(candidate);
if (SUPPORTED_UNIFIED_RESOURCE_SOURCES.has(normalizedSource)) {
sources.add(normalizedSource);
if (key === 'excludeSource') {
excludedSources.add(normalizedSource);
} else {
sources.add(normalizedSource);
}
}
});
}
return types.size > 0 || sources.size > 0 ? { types, sources } : null;
return types.size > 0 || sources.size > 0 || excludedSources.size > 0
? { types, sources, excludedSources }
: null;
};
const filterCanonicalUnifiedResources = (
@@ -1209,19 +1217,20 @@ const filterCanonicalUnifiedResources = (
if (localFilter.types.size > 0 && !localFilter.types.has(resolveType(resource.type))) {
return false;
}
if (localFilter.sources.size === 0) {
return true;
}
const platformSources = resource.platformData?.sources;
const resourceSources = [
...(resource.sources ?? []),
...(Array.isArray(platformSources)
? platformSources.filter((source): source is string => typeof source === 'string')
: []),
];
return resourceSources.some((source) =>
localFilter.sources.has(normalizeUnifiedResourceSource(source)),
);
].map(normalizeUnifiedResourceSource);
if (
localFilter.sources.size > 0 &&
!resourceSources.some((source) => localFilter.sources.has(source))
) {
return false;
}
return !resourceSources.some((source) => localFilter.excludedSources.has(source));
});
};
+38 -24
View File
@@ -1942,34 +1942,36 @@ func storageSummaryAffectedDatastoreCount(resource unified.Resource) int {
// Filtering helpers.
type listFilters struct {
types map[unified.ResourceType]struct{}
sources map[unified.DataSource]struct{}
statuses map[unified.ResourceStatus]struct{}
parent string
cluster string
namespace string
query string
tags map[string]struct{}
page int
limit int
sortField string
sortOrder string
types map[unified.ResourceType]struct{}
sources map[unified.DataSource]struct{}
excludedSources map[unified.DataSource]struct{}
statuses map[unified.ResourceStatus]struct{}
parent string
cluster string
namespace string
query string
tags map[string]struct{}
page int
limit int
sortField string
sortOrder string
}
func parseListFilters(r *http.Request) listFilters {
filters := listFilters{
types: parseResourceTypes(r.URL.Query().Get("type")),
sources: parseSources(r.URL.Query().Get("source")),
statuses: parseStatuses(r.URL.Query().Get("status")),
parent: strings.TrimSpace(r.URL.Query().Get("parent")),
cluster: strings.TrimSpace(r.URL.Query().Get("cluster")),
namespace: strings.TrimSpace(r.URL.Query().Get("namespace")),
query: strings.TrimSpace(strings.ToLower(r.URL.Query().Get("q"))),
tags: parseTags(r.URL.Query().Get("tags")),
page: parseIntDefault(r.URL.Query().Get("page"), 1),
limit: parseIntDefault(r.URL.Query().Get("limit"), 50),
sortField: strings.TrimSpace(r.URL.Query().Get("sort")),
sortOrder: strings.TrimSpace(strings.ToLower(r.URL.Query().Get("order"))),
types: parseResourceTypes(r.URL.Query().Get("type")),
sources: parseSources(r.URL.Query().Get("source")),
excludedSources: parseSources(r.URL.Query().Get("excludeSource")),
statuses: parseStatuses(r.URL.Query().Get("status")),
parent: strings.TrimSpace(r.URL.Query().Get("parent")),
cluster: strings.TrimSpace(r.URL.Query().Get("cluster")),
namespace: strings.TrimSpace(r.URL.Query().Get("namespace")),
query: strings.TrimSpace(strings.ToLower(r.URL.Query().Get("q"))),
tags: parseTags(r.URL.Query().Get("tags")),
page: parseIntDefault(r.URL.Query().Get("page"), 1),
limit: parseIntDefault(r.URL.Query().Get("limit"), 50),
sortField: strings.TrimSpace(r.URL.Query().Get("sort")),
sortOrder: strings.TrimSpace(strings.ToLower(r.URL.Query().Get("order"))),
}
if filters.page < 1 {
filters.page = 1
@@ -2009,6 +2011,18 @@ func applyFilters(resources []unified.Resource, filters listFilters) []unified.R
continue
}
}
if len(filters.excludedSources) > 0 {
excluded := false
for _, source := range r.Sources {
if _, ok := filters.excludedSources[source]; ok {
excluded = true
break
}
}
if excluded {
continue
}
}
if len(filters.statuses) > 0 {
if _, ok := filters.statuses[r.Status]; !ok {
continue
@@ -4451,3 +4451,40 @@ func TestParseSources_AcceptsVMwareAliases(t *testing.T) {
}
}
}
func TestResourceListFiltersExcludeAnyMatchingSource(t *testing.T) {
resources := []unified.Resource{
{
ID: "standalone-agent",
Name: "standalone-agent",
Type: unified.ResourceTypeAgent,
Sources: []unified.DataSource{unified.SourceAgent, unified.SourceDocker},
},
{
ID: "proxmox-agent",
Name: "proxmox-agent",
Type: unified.ResourceTypeAgent,
Sources: []unified.DataSource{unified.SourceAgent, unified.SourceProxmox},
},
{
ID: "availability-target",
Name: "availability-target",
Type: unified.ResourceTypeNetworkEndpoint,
Sources: []unified.DataSource{unified.SourceAvailability},
},
}
req := httptest.NewRequest(
http.MethodGet,
"/api/resources?source=agent,availability&excludeSource=proxmox,kubernetes,truenas,vmware-vsphere&type=agent,network-endpoint",
nil,
)
filtered := applyFilters(resources, parseListFilters(req))
if len(filtered) != 2 {
t.Fatalf("expected 2 standalone resources, got %d: %#v", len(filtered), filtered)
}
if filtered[0].ID != "standalone-agent" || filtered[1].ID != "availability-target" {
t.Fatalf("unexpected standalone resource projection: %#v", filtered)
}
}