From 32a215203ce2d3ce317f81705dd043883cef3b35 Mon Sep 17 00:00:00 2001 From: "courtmanr@gmail.com" Date: Thu, 30 Jul 2026 17:19:52 +0100 Subject: [PATCH] feat(storage): surface PBS host physical disks --- .../subsystems/frontend-primitives.md | 19 +++ .../internal/subsystems/storage-recovery.md | 21 +++ .../internal/subsystems/unified-resources.md | 16 +++ .../components/Storage/StoragePoolDetail.tsx | 2 +- .../__tests__/storagePageState.test.ts | 7 + .../useStoragePoolDetailModel.test.ts | 1 + .../components/Storage/storagePageState.ts | 5 +- .../features/proxmox/ProxmoxPageSurface.tsx | 2 +- .../__tests__/ProxmoxBackupsTable.test.tsx | 8 ++ .../ProxmoxPageSurface.contract.test.tsx | 25 +++- .../__tests__/diskPresentation.test.ts | 27 +++- .../__tests__/storageModelCore.test.ts | 21 +++ ...gePoolDetailPresentation.branchcov.test.ts | 4 + .../storageBackups/diskPresentation.ts | 4 +- .../storageBackups/storageModelCore.ts | 10 +- .../frontendResourceTypeBoundaries.test.ts | 2 +- .../utils/__tests__/storageSources.test.ts | 9 ++ frontend-modern/src/utils/storageSources.ts | 11 ++ internal/unifiedresources/pbs_host_disks.go | 123 +++++++++++++++++ .../unifiedresources/pbs_pmg_registry_test.go | 126 ++++++++++++++++++ internal/unifiedresources/registry.go | 1 + 21 files changed, 430 insertions(+), 14 deletions(-) create mode 100644 internal/unifiedresources/pbs_host_disks.go diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 443bb1bf37..85342c6a42 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -5548,3 +5548,22 @@ default history groups unchanged. Rendered table proof belongs in `frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx`; drawer grouping and fallback proof belongs in `frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts`. + +### Proxmox Storage reuses the shared product-family source scope + +`frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx` mounts the +canonical shared Storage surface for its Storage tab and supplies the hidden +`proxmox-all` filter. Shared storage source matching owns that internal +umbrella and admits normalized PVE, PBS, and other Proxmox-family source keys; +visible source-picker filters remain exact. The page must not create a +PBS-only disk table or duplicate storage state in the Proxmox feature. + +The rendered route contract in +`frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx` +and the shared-surface source guard in +`frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx` +pin this composition boundary. + +ZFS dataset table rows inside `StoragePoolDetail` inherit their separator from +the shared `STORAGE_DETAIL_ROW_CLASS` presentation constant. The component +must not reintroduce a raw border-token class for dataset rows. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 06924e2f48..62b41774b3 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -4834,3 +4834,24 @@ destroy, or alter datasets, and the server does not persist a second provider inventory. Optional dataset rows travel with the current host snapshot and only enrich the matching Proxmox pool presentation; backup and restore authority, storage mutation paths, and recovery semantics are unchanged. + +The shared Proxmox Storage route uses the internal `proxmox-all` source scope +to include normalized Proxmox-family storage rows and physical disks across +PVE and PBS instead of silently narrowing the workspace to PVE. This umbrella +scope is route-owned and hidden; ordinary source-picker selections remain +exact, so selecting PVE does not admit PBS and selecting PBS does not admit +PVE. Agent-reported SMART disks correlated to a unique PBS host may carry PBS +platform membership for this filter, but their health and device facts remain +agent telemetry rather than PBS API inventory. + +`frontend-modern/src/features/storageBackups/__tests__/storageModelCore.test.ts`, +`frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts`, +and `frontend-modern/src/components/Storage/__tests__/storagePageState.test.ts` +pin the record, disk, and node-scope behavior. The Proxmox surface wiring is +also guarded by +`frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx`. + +The ZFS pool-detail summary has one stable shape: `datasets` is always an +array, including when the backend reports no dataset inventory. Consumers and +branch proofs must retain the empty array instead of treating dataset support +as an optional return-field contract. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 3718dd95ea..7aaaa0a824 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -4210,6 +4210,22 @@ provider-local alert state. `internal/unifiedresources/ceph_pool_health_contract_test.go`, and `internal/api/resources_pool_health_contract_test.go` pin the normalized and wire-level contract. + +### PBS host physical-disk correlation preserves agent telemetry authority + +`internal/unifiedresources/pbs_host_disks.go` may add `pbs` source membership +to an agent host and its agent-reported SMART disk resources only when one +host is uniquely corroborated by the PBS instance name, endpoint hostname, or +endpoint IP against the host report and interface addresses. Multiple matches +are ambiguous and must fail closed without assigning PBS ownership. + +PBS does not supply the SMART inventory. Disk identity, health, temperature, +and other typed physical-disk facts therefore remain agent-owned, while the +PBS source mapping and PBS-parent relationship make the existing disk +discoverable in Proxmox Backup Server storage context. Canonical parent and +fact precedence continue to follow the existing source-priority rules. +`internal/unifiedresources/pbs_pmg_registry_test.go` pins the unique-match and +ambiguous-match boundaries. That same shared-consumer boundary now also owns VMware phase-1 detail presentation. `frontend-modern/src/components/Infrastructure/` `resourceDetailDrawerVmwareModel.ts`, diff --git a/frontend-modern/src/components/Storage/StoragePoolDetail.tsx b/frontend-modern/src/components/Storage/StoragePoolDetail.tsx index 01d73bd6a0..b666f50a52 100644 --- a/frontend-modern/src/components/Storage/StoragePoolDetail.tsx +++ b/frontend-modern/src/components/Storage/StoragePoolDetail.tsx @@ -201,7 +201,7 @@ export const StoragePoolDetail: Component = (props) => { {(dataset) => ( - + { expect(storageResourceMatchesSourceFilter(node, 'all')).toBe(true); expect(storageResourceMatchesSourceFilter(node, 'proxmox-pve')).toBe(true); + expect(storageResourceMatchesSourceFilter(node, 'proxmox-all')).toBe(true); expect(storageResourceMatchesSourceFilter(node, 'truenas')).toBe(false); + + const pbsNode = makeNode({ + platformType: 'proxmox-pbs', + platformData: { sources: ['pbs', 'agent'] }, + }); + expect(storageResourceMatchesSourceFilter(pbsNode, 'proxmox-all')).toBe(true); }); it('keeps expanded storage groups canonical across data refreshes', () => { diff --git a/frontend-modern/src/components/Storage/__tests__/useStoragePoolDetailModel.test.ts b/frontend-modern/src/components/Storage/__tests__/useStoragePoolDetailModel.test.ts index 8409a99bd6..187507a5e6 100644 --- a/frontend-modern/src/components/Storage/__tests__/useStoragePoolDetailModel.test.ts +++ b/frontend-modern/src/components/Storage/__tests__/useStoragePoolDetailModel.test.ts @@ -84,6 +84,7 @@ describe('useStoragePoolDetailModel', () => { scan: 'scrub in progress', errorSummary: null, devices: [{ name: 'sda', type: '', state: '', errorSummary: '', message: '' }], + datasets: [], }); expect(result.linkedDisks()).toEqual([ { diff --git a/frontend-modern/src/components/Storage/storagePageState.ts b/frontend-modern/src/components/Storage/storagePageState.ts index bdea594946..4c7d1b9221 100644 --- a/frontend-modern/src/components/Storage/storagePageState.ts +++ b/frontend-modern/src/components/Storage/storagePageState.ts @@ -10,6 +10,7 @@ import type { Resource } from '@/types/resource'; import { getProxmoxData } from '@/utils/resourcePlatformData'; import { getResourceIdentityAliases } from '@/utils/resourceIdentity'; import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms'; +import { storageSourceMatchesFilter } from '@/utils/storageSources'; import { matchesPhysicalDiskNode } from './diskResourceUtils'; import type { StorageGroupKey, StorageSortKey } from './useStorageModel'; import type { StorageRouteStateFields } from './useStorageRouteState'; @@ -375,8 +376,8 @@ export const storageResourceMatchesSourceFilter = ( ? platformData.sources.filter((source): source is string => typeof source === 'string') : []; const candidates = [resource.platformType, resource.sourceType, ...sources]; - return candidates.some( - (candidate) => normalizeSourcePlatformQueryValue(candidate || '') === selectedSource, + return candidates.some((candidate) => + storageSourceMatchesFilter(normalizeSourcePlatformQueryValue(candidate || ''), selectedSource), ); }; diff --git a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx index 319138b98b..c4e57a2ad6 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx @@ -62,7 +62,7 @@ import { const PROXMOX_RESOURCE_QUERY = 'type=agent,vm,system-container,oci-container,storage,physical_disk,ceph,pbs,pmg'; -const PROXMOX_PLATFORM_FILTER = 'proxmox-pve'; +const PROXMOX_PLATFORM_FILTER = 'proxmox-all'; const PROXMOX_WORKLOAD_STATUS_STORAGE_SCOPE = 'proxmox'; const PROXMOX_WORKLOAD_EXCLUDED_TYPES = ['app-container'] as const; const VALID_TABS = new Set(PROXMOX_TAB_SPECS.map((tab) => tab.id)); diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx index 501e8cd30c..8fdb28a7df 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx @@ -377,6 +377,14 @@ describe('ProxmoxBackupsTable', () => { expect(proxmoxPageSurfaceSource).not.toContain('workloads={workloadsState.allGuests'); }); + it('keeps the shared storage surface scoped to the whole Proxmox product family', () => { + expect(proxmoxPageSurfaceSource).toContain("const PROXMOX_PLATFORM_FILTER = 'proxmox-all';"); + expect(proxmoxPageSurfaceSource).toContain('forcedSourceFilter={PROXMOX_PLATFORM_FILTER}'); + expect(proxmoxPageSurfaceSource).not.toContain( + "const PROXMOX_PLATFORM_FILTER = 'proxmox-pve';", + ); + }); + it('keeps Patrol coverage out of Proxmox evidence surfaces', () => { expect(proxmoxPageSurfaceSource).not.toContain('getMonitorContextPatrolProtectionPosture'); expect(proxmoxPageSurfaceSource).not.toContain('getPatrolRunHistory(1)'); diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx index ff063d6748..2bcec806ad 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx @@ -6,6 +6,7 @@ import { ProxmoxPageSurface } from '../ProxmoxPageSurface'; const mockUseUnifiedResources = vi.fn(); const mockPathname = vi.hoisted(() => vi.fn(() => '/proxmox/overview')); const mockVersionInfo = vi.hoisted(() => vi.fn()); +const mockStorageProps = vi.hoisted(() => vi.fn()); const makeResource = (resource: Partial & Pick): Resource => ({ @@ -61,7 +62,10 @@ vi.mock('@solidjs/router', async () => { }); vi.mock('@/components/Storage/Storage', () => ({ - default: () =>
, + default: (props: { forcedSourceFilter?: string }) => { + mockStorageProps(props); + return
; + }, })); vi.mock('@/components/Workloads/WorkloadsFilter', () => ({ @@ -132,6 +136,25 @@ describe('ProxmoxPageSurface contract', () => { vi.clearAllMocks(); }); + it('scopes the storage tab to both PVE and PBS resources', () => { + mockPathname.mockReturnValue('/proxmox/storage'); + setResources([ + makeResource({ + id: 'pbs-datastore-1', + type: 'storage', + platformType: 'proxmox-pbs', + sources: ['pbs'], + }), + ]); + + render(() => ); + + expect(screen.getByTestId('storage-surface')).toBeInTheDocument(); + expect(mockStorageProps).toHaveBeenCalledWith( + expect.objectContaining({ forcedSourceFilter: 'proxmox-all' }), + ); + }); + it('surfaces stale agent-backed Proxmox nodes', () => { mockVersionInfo.mockReturnValue({ version: 'v6.0.0-rc.6', diff --git a/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts index 55442305ba..0ac5664be9 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts @@ -433,6 +433,27 @@ describe('diskPresentation', () => { matchesNode: () => true, }).map((disk) => disk.id), ).toEqual(['disk-warning']); + expect( + filterAndSortPhysicalDisks( + [ + warningDisk, + { + ...healthyDisk, + id: 'pbs-disk', + platformType: 'proxmox-pbs', + sources: ['agent', 'pbs'], + }, + ], + { + selectedNode: null, + searchTerm: '', + sourceFilter: 'proxmox-all', + healthFilter: 'all', + getDiskData: (disk) => (disk.id === warningDisk.id ? warningData : healthyData), + matchesNode: () => true, + }, + ).map((disk) => disk.id), + ).toEqual(['disk-warning', 'pbs-disk']); expect( filterAndSortPhysicalDisks([warningDisk, healthyDisk], { selectedNode: null, @@ -461,10 +482,12 @@ describe('wearout evidence boundary', () => { it('flags a spent SSD but leaves a rotational zero alone', () => { expect( - getPhysicalDiskHealthStatus(makeDiskData({ health: 'PASSED', wearout: 0, type: 'ssd' })).label, + getPhysicalDiskHealthStatus(makeDiskData({ health: 'PASSED', wearout: 0, type: 'ssd' })) + .label, ).toBe('Needs Attention'); expect( - getPhysicalDiskHealthStatus(makeDiskData({ health: 'PASSED', wearout: 0, type: 'hdd' })).label, + getPhysicalDiskHealthStatus(makeDiskData({ health: 'PASSED', wearout: 0, type: 'hdd' })) + .label, ).toBe('Healthy'); }); diff --git a/frontend-modern/src/features/storageBackups/__tests__/storageModelCore.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storageModelCore.test.ts index 8227dde8e8..7603127cf2 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storageModelCore.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storageModelCore.test.ts @@ -120,6 +120,27 @@ describe('storageModelCore', () => { }); }); + it('keeps both PVE pools and PBS datastores in the Proxmox workspace scope', () => { + const pbs = makeRecord({ + id: 'pbs-datastore', + source: { + platform: 'proxmox-pbs', + family: 'virtualization', + origin: 'resource', + adapterId: 'resource-storage', + }, + }); + + expect( + filterStorageRecords([makeRecord(), pbs], { + search: '', + sourceFilter: 'proxmox-all', + healthFilter: 'all', + selectedNode: null, + }).map((record) => record.id), + ).toEqual(['storage-1', 'pbs-datastore']); + }); + it('sorts storage records by the visible pool-table columns', () => { const alpha = makeRecord({ id: 'storage-alpha', diff --git a/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.branchcov.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.branchcov.test.ts index 4c1627996f..ce66441060 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.branchcov.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.branchcov.test.ts @@ -258,6 +258,7 @@ describe('buildStoragePoolDetailZfsSummary branch coverage', () => { scan: '', errorSummary: null, devices: [], + datasets: [], }); }); @@ -279,6 +280,7 @@ describe('buildStoragePoolDetailZfsSummary branch coverage', () => { scan: '', errorSummary: null, devices: [], + datasets: [], }); }); @@ -300,6 +302,7 @@ describe('buildStoragePoolDetailZfsSummary branch coverage', () => { scan: 'scrub ok', errorSummary: null, devices: [{ name: 'sdc', type: '', state: '', errorSummary: '', message: '' }], + datasets: [], }); }); @@ -321,6 +324,7 @@ describe('buildStoragePoolDetailZfsSummary branch coverage', () => { scan: '', errorSummary: 'Errors: R:1 W:0 C:0', devices: [], + datasets: [], }); }); }); diff --git a/frontend-modern/src/features/storageBackups/diskPresentation.ts b/frontend-modern/src/features/storageBackups/diskPresentation.ts index 1fbffd4738..973dc7ee27 100644 --- a/frontend-modern/src/features/storageBackups/diskPresentation.ts +++ b/frontend-modern/src/features/storageBackups/diskPresentation.ts @@ -11,7 +11,7 @@ import { import { getAllFilterOptionLabel } from '@/components/shared/filterOptionPresentation'; import { getPhysicalDiskNodeIdentity } from '@/components/Storage/diskResourceUtils'; import { getInfrastructureSettingsLocationLabel } from '@/utils/infrastructureSettingsPresentation'; -import { normalizeStorageSourceKey } from '@/utils/storageSources'; +import { normalizeStorageSourceKey, storageSourceMatchesFilter } from '@/utils/storageSources'; import type { NormalizedHealth, StorageHealthFilter } from './models'; import { matchesStorageNodeTerms, parseStorageSearchQuery } from './storageSearchQuery'; @@ -452,7 +452,7 @@ export function matchesPhysicalDiskFilterState( }, ): boolean { const selectedSource = normalizeStorageSourceKey(options.sourceFilter || 'all'); - if (selectedSource !== 'all' && getPhysicalDiskSourceKey(resource) !== selectedSource) { + if (!storageSourceMatchesFilter(getPhysicalDiskSourceKey(resource), selectedSource)) { return false; } diff --git a/frontend-modern/src/features/storageBackups/storageModelCore.ts b/frontend-modern/src/features/storageBackups/storageModelCore.ts index 6550892ae0..e5baeec6cd 100644 --- a/frontend-modern/src/features/storageBackups/storageModelCore.ts +++ b/frontend-modern/src/features/storageBackups/storageModelCore.ts @@ -1,5 +1,9 @@ import type { NormalizedHealth, StorageHealthFilter, StorageRecord } from './models'; -import { normalizeStorageSourceKey, orderStorageSourceKeys } from '@/utils/storageSources'; +import { + normalizeStorageSourceKey, + orderStorageSourceKeys, + storageSourceMatchesFilter, +} from '@/utils/storageSources'; import { matchesStorageNodeTerms, parseStorageSearchQuery } from './storageSearchQuery'; import type { StorageCapacityDeltaPresentation } from './storageCapacityDeltaPresentation'; import { resolveStorageRecordMetricResourceId } from './storageMetricsIdentity'; @@ -146,9 +150,7 @@ export const filterStorageRecords = ( const selectedSource = normalizeStorageSourceKey(options.sourceFilter); return records .filter((record) => - selectedSource === 'all' - ? true - : normalizeStorageSourceKey(record.source.platform) === selectedSource, + storageSourceMatchesFilter(normalizeStorageSourceKey(record.source.platform), selectedSource), ) .filter((record) => matchesStorageHealthFilter(record.health, options.healthFilter)) .filter((record) => matchesStorageRecordNode(record, options.selectedNode)) diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index dfc09b70fb..67dfe168bf 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -1605,7 +1605,7 @@ describe('frontend resource type boundaries', () => { ); expect(storageDetailKeyValueRowSource).toContain('STORAGE_DETAIL_KEY_VALUE_ROW_CLASS'); expect(storageDetailMetricCardSource).toContain('STORAGE_DETAIL_CARD_CLASS'); - expect(storagePoolDetailSource).toContain('STORAGE_DETAIL_ROW_CLASS'); + expect(storagePoolDetailSource).toContain(''); expect(storagePoolDetailSource).not.toContain('border-t border-border'); expect(storagePoolDetailSource).not.toContain('col-span-2'); expect(storagePoolDetailSource).not.toContain('text-base-content truncate flex-1'); diff --git a/frontend-modern/src/utils/__tests__/storageSources.test.ts b/frontend-modern/src/utils/__tests__/storageSources.test.ts index 352e3adbba..582142325a 100644 --- a/frontend-modern/src/utils/__tests__/storageSources.test.ts +++ b/frontend-modern/src/utils/__tests__/storageSources.test.ts @@ -6,6 +6,7 @@ import { normalizeStorageSourceKey, orderStorageSourceKeys, resolveStorageSourceKey, + storageSourceMatchesFilter, type StorageSourceOption, } from '@/utils/storageSources'; @@ -29,6 +30,14 @@ const makeStorage = (overrides: Partial>): St }) as Storage; describe('storageSources', () => { + it('matches the hidden Proxmox workspace scope across PVE, PBS, and PMG', () => { + expect(storageSourceMatchesFilter('proxmox-pve', 'proxmox-all')).toBe(true); + expect(storageSourceMatchesFilter('proxmox-pbs', 'proxmox-all')).toBe(true); + expect(storageSourceMatchesFilter('proxmox-pmg', 'proxmox-all')).toBe(true); + expect(storageSourceMatchesFilter('truenas', 'proxmox-all')).toBe(false); + expect(storageSourceMatchesFilter('proxmox-pbs', 'proxmox-pve')).toBe(false); + }); + describe('normalizeStorageSourceKey', () => { it.each([ ['k8s', 'kubernetes'], diff --git a/frontend-modern/src/utils/storageSources.ts b/frontend-modern/src/utils/storageSources.ts index cfd89c6c25..e4295eb562 100644 --- a/frontend-modern/src/utils/storageSources.ts +++ b/frontend-modern/src/utils/storageSources.ts @@ -74,6 +74,17 @@ export const normalizeStorageSourceKey = (value: string | null | undefined): str } }; +export const storageSourceMatchesFilter = ( + source: string | null | undefined, + filter: string | null | undefined, +): boolean => { + const sourceKey = normalizeStorageSourceKey(source); + const filterKey = normalizeStorageSourceKey(filter); + if (!filterKey || filterKey === 'all') return true; + if (filterKey === 'proxmox-all') return sourceKey.startsWith('proxmox-'); + return sourceKey === filterKey; +}; + export const resolveStorageSourceKey = (storage: Storage): string => { // Prefer the explicit canonical platform tag when the storage adapter // already declared it (TrueNAS, VMware, Hyper-V, etc. set diff --git a/internal/unifiedresources/pbs_host_disks.go b/internal/unifiedresources/pbs_host_disks.go new file mode 100644 index 0000000000..6caa2c899c --- /dev/null +++ b/internal/unifiedresources/pbs_host_disks.go @@ -0,0 +1,123 @@ +package unifiedresources + +import ( + "fmt" + "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) + +// associatePBSHostAgentResources projects a corroborated PBS host-agent +// relationship onto the existing host and physical-disk resources. PBS does +// not expose SMART inventory through its API, so the disk facts remain +// agent-owned; the PBS source membership only makes their platform ownership +// explicit for shared Proxmox storage consumers. +func (rr *ResourceRegistry) associatePBSHostAgentResources( + instance models.PBSInstance, + hosts []models.Host, +) { + host := uniquePBSHostAgent(instance, hosts) + if host == nil { + return + } + + pbsSourceID := pbsInstanceSourceID(instance) + pbsParentID := rr.sourceResourceID(SourcePBS, pbsSourceID) + if pbsSourceID == "" || pbsParentID == "" { + return + } + + rr.mu.Lock() + defer rr.mu.Unlock() + + if rr.bySource[SourcePBS] == nil { + rr.bySource[SourcePBS] = make(map[string]string) + } + + attach := func(sourceID, canonicalID, parentID string) { + sourceID = normalizeSourceID(sourceID) + canonicalID = CanonicalResourceID(canonicalID) + if sourceID == "" || canonicalID == "" { + return + } + resource := rr.resources[canonicalID] + if resource == nil { + return + } + resource.Sources = addSource(resource.Sources, SourcePBS) + if resource.SourceStatus == nil { + resource.SourceStatus = make(map[DataSource]SourceStatus) + } + resource.SourceStatus[SourcePBS] = SourceStatus{ + Status: sourceSightingStatus(instance.LastSeen), + LastSeen: instance.LastSeen, + } + if parentID != "" { + rr.setSourceParent(resource, SourcePBS, &parentID) + resource.ParentID = rr.resolveCanonicalParentID(resource) + } + rr.bySource[SourcePBS][sourceID] = canonicalID + } + + hostCanonicalID := rr.bySource[SourceAgent][normalizeSourceID(host.ID)] + attach( + fmt.Sprintf("%s/agent:%s", pbsSourceID, strings.TrimSpace(host.ID)), + hostCanonicalID, + "", + ) + + for _, disk := range host.Sensors.SMART { + agentDiskSourceID := HostSMARTDiskSourceID(*host, disk) + diskCanonicalID := rr.bySource[SourceAgent][normalizeSourceID(agentDiskSourceID)] + attach( + fmt.Sprintf("%s/disk:%s", pbsSourceID, agentDiskSourceID), + diskCanonicalID, + pbsParentID, + ) + } + rr.viewsDirty = true +} + +func uniquePBSHostAgent(instance models.PBSInstance, hosts []models.Host) *models.Host { + var match *models.Host + for index := range hosts { + if !pbsInstanceCorroboratesHost(instance, hosts[index]) { + continue + } + if match != nil { + return nil + } + match = &hosts[index] + } + return match +} + +func pbsInstanceCorroboratesHost(instance models.PBSInstance, host models.Host) bool { + hostName := NormalizeHostname(host.Hostname) + if hostName == "" { + return false + } + if instanceName := NormalizeHostname(instance.Name); instanceName != "" && instanceName == hostName { + return true + } + + endpoint := strings.TrimSpace(strings.ToLower(extractHostname(instance.Host))) + if endpoint == "" { + return false + } + if ip := NormalizeIP(endpoint); ip != "" { + if NormalizeIP(host.ReportIP) == ip { + return true + } + for _, iface := range host.NetworkInterfaces { + for _, address := range iface.Addresses { + if NormalizeIP(address) == ip { + return true + } + } + } + return false + } + + return NormalizeHostname(endpoint) == hostName +} diff --git a/internal/unifiedresources/pbs_pmg_registry_test.go b/internal/unifiedresources/pbs_pmg_registry_test.go index 4756483396..778aaf3779 100644 --- a/internal/unifiedresources/pbs_pmg_registry_test.go +++ b/internal/unifiedresources/pbs_pmg_registry_test.go @@ -191,3 +191,129 @@ func TestIngestSnapshotIncludesPBSAndPMGInstances(t *testing.T) { t.Fatalf("expected PMG mail count points, got %+v", pmgResource.PMG.MailCount) } } + +func TestIngestSnapshotAssociatesPBSHostAgentPhysicalDisks(t *testing.T) { + now := time.Now().UTC() + registry := NewRegistry(nil) + registry.IngestSnapshot(models.StateSnapshot{ + Hosts: []models.Host{ + { + ID: "agent-pbs-1", + Hostname: "pbs-one.local", + MachineID: "machine-pbs-1", + Status: "online", + LastSeen: now, + Sensors: models.HostSensorSummary{ + SMART: []models.HostDiskSMART{ + { + Device: "/dev/sda", + Model: "Backup Drive", + Serial: "PBS-DISK-1", + Health: "PASSED", + Temperature: 36, + }, + }, + }, + }, + }, + PBSInstances: []models.PBSInstance{ + { + ID: "pbs-1", + Name: "pbs-one", + Host: "https://pbs-one.example:8007", + Status: "online", + LastSeen: now, + }, + }, + }) + + var hostResource *Resource + var diskResource *Resource + for _, resource := range registry.List() { + switch resource.Type { + case ResourceTypeAgent: + if resource.Agent != nil && resource.Agent.AgentID == "agent-pbs-1" { + copy := resource + hostResource = © + } + case ResourceTypePhysicalDisk: + if resource.PhysicalDisk != nil && resource.PhysicalDisk.Serial == "PBS-DISK-1" { + copy := resource + diskResource = © + } + } + } + if hostResource == nil || diskResource == nil { + t.Fatalf("expected correlated PBS host and disk, host=%+v disk=%+v", hostResource, diskResource) + } + if !containsDataSource(hostResource.Sources, SourcePBS) { + t.Fatalf("PBS host sources = %v, want PBS membership", hostResource.Sources) + } + if !containsDataSource(diskResource.Sources, SourceAgent) || + !containsDataSource(diskResource.Sources, SourcePBS) { + t.Fatalf("PBS disk sources = %v, want agent and PBS", diskResource.Sources) + } + if diskResource.ParentID == nil || *diskResource.ParentID != hostResource.ID { + t.Fatalf( + "PBS disk parent = %v, want agent host %q to retain canonical parent authority", + diskResource.ParentID, + hostResource.ID, + ) + } + RefreshCanonicalMetadata(diskResource) + if !containsPlatformScope(diskResource.PlatformScopes, "proxmox-pbs") { + t.Fatalf("PBS disk platform scopes = %v, want proxmox-pbs", diskResource.PlatformScopes) + } +} + +func TestIngestSnapshotSkipsAmbiguousPBSHostAgentAssociation(t *testing.T) { + now := time.Now().UTC() + hosts := []models.Host{ + { + ID: "agent-pbs-a", + Hostname: "pbs-one.local", + Status: "online", + LastSeen: now, + Sensors: models.HostSensorSummary{ + SMART: []models.HostDiskSMART{{Device: "/dev/sda", Serial: "PBS-A"}}, + }, + }, + { + ID: "agent-pbs-b", + Hostname: "pbs-one.example", + Status: "online", + LastSeen: now, + Sensors: models.HostSensorSummary{ + SMART: []models.HostDiskSMART{{Device: "/dev/sdb", Serial: "PBS-B"}}, + }, + }, + } + registry := NewRegistry(nil) + registry.IngestSnapshot(models.StateSnapshot{ + Hosts: hosts, + PBSInstances: []models.PBSInstance{ + { + ID: "pbs-1", + Name: "pbs-one", + Host: "https://pbs-one.example:8007", + Status: "online", + LastSeen: now, + }, + }, + }) + + for _, resource := range registry.ListByType(ResourceTypePhysicalDisk) { + if containsDataSource(resource.Sources, SourcePBS) { + t.Fatalf("ambiguous PBS host disk gained PBS source membership: %+v", resource) + } + } +} + +func containsPlatformScope(values []string, expected string) bool { + for _, value := range values { + if value == expected { + return true + } + } + return false +} diff --git a/internal/unifiedresources/registry.go b/internal/unifiedresources/registry.go index 1bfb18fc3d..89e82086da 100644 --- a/internal/unifiedresources/registry.go +++ b/internal/unifiedresources/registry.go @@ -211,6 +211,7 @@ func (rr *ResourceRegistry) ingestSnapshot(snapshot models.StateSnapshot, thresh } for _, instance := range snapshot.PBSInstances { rr.ingestPBSInstance(instance) + rr.associatePBSHostAgentResources(instance, snapshot.Hosts) } for _, instance := range snapshot.PMGInstances { rr.ingestPMGInstance(instance)