mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Scope remaining provider inventory hydration
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user