diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 3de4cd648..a1bc7e83c 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -2664,6 +2664,15 @@ Agent` secondary handoff against the live setup wizard instead of relying ## Current State +### Linked-agent interface precedence remains singular + +A Proxmox node may now carry PVE API interface inventory alongside an optional +linked Unified Agent. This does not create a second agent identity or lifecycle +channel: the linked agent remains authoritative for richer live interface +telemetry, and the shared drawer presents that view once instead of rendering +both source inventories. `internal/models/metrics_types_test.go` pins the node +report collection shape used at this merge boundary. + ### Docker update preflight and execution share one late-bound capability The unified agent's late-bound Docker updater now carries the read-only typed diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 0fac683e6..fc83b56f8 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -9853,3 +9853,18 @@ it appears only when the agent reported a profile, it never carries credentials or paths, and consumers must not derive health status from it. The unified agent report contract (`pkg/agents/host/report.go`) gains the matching agent-authored `privilege` block with the same fields. + +### Proxmox node network inventory is an optional canonical facet + +Canonical Proxmox node resources may carry `proxmox.networkInterfaces`, using +the existing host-interface wire shape: a required interface name, optional +MAC and counters, and an optional address list. The provider projection +populates configured interface names plus IPv4/IPv6 addresses from the PVE +node-network endpoint; older servers and cached resources may omit the field. +The frontend transport and legacy node adapter must preserve this optional +facet without changing resource identity, status, or metric fields. A linked +agent may supply the richer presentation source, but does not change the PVE +facet on the wire. `internal/models/metrics_types_test.go`, +`frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts`, and +`frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts` pin the +wire shape and both client projections. diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index e9924dadf..c356896a1 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -1661,6 +1661,15 @@ artifact-selection behaviour. ## Current State +### Candidate notes cover restored Proxmox node network details + +The current v6.4 candidate notes record that configured PVE node interface +names and IPv4/IPv6 addresses are visible without a linked agent. This is a +runtime presentation addition only: it changes no installer permission, +artifact, upgrade, rollback, signing, or promotion boundary. +`frontend-modern/src/utils/__tests__/docsLinks.test.ts` pins the candidate-note +line so the release packet cannot silently omit the user-visible restoration. + Pulse v6.2.1 is the first active published release with the signed, evaluation-capable provider MSP deploy bundle. Public MSP guidance pins that exact version, verifies its detached SSH signature and checksum before root diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 379216107..aed2edf72 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -1107,6 +1107,20 @@ cleanup so readers cannot retain orphaned runtime or alert projections. ## Current State +### Proxmox node network inventory is secondary and continuity-safe + +Online PVE node polls read `/nodes/{node}/network` through an optional client +capability after the primary status read. Standalone and cluster clients both +support it; clusters retain normal endpoint failover. Interface inventory never +turns a healthy node poll into a failure: transient errors and offline cycles +retain last-known data, while an authoritative empty response clears it. CIDR +is preferred over a duplicate bare IPv4 value, IPv6 is preserved, configured +bridges remain visible, and output order is stable by interface name. +`internal/monitoring/monitor_pve_cluster_refresh_test.go`, +`internal/monitoring/node_memory_sources_test.go`, and +`internal/models/metrics_types_test.go` pin mapping, failure continuity, and +the runtime report shape. + ### Poll task concurrency remains bounded across tenants Without an override, each monitor retains the established client-derived diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 5032028fb..32fce6c09 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -2770,3 +2770,15 @@ checks before handing the request to either body-decoding handler. Denied requests perform no configuration export loads, import transaction work, metadata replacement, or runtime reload. Authorized archive encryption, decryption, transactional persistence, and reload costs are unchanged. + +### Proxmox node network inventory uses a bounded refresh cadence + +Configured node interfaces are slow-changing provider inventory, not a hot +metric. Each online node may perform one bounded PVE network read on initial +observation and at most once per five-minute wall-clock window thereafter; +ordinary node polls within the same window reuse the deep-copied last-known +inventory. Offline nodes perform no network read. The call uses the existing +poll context and cluster failover boundary, adds no goroutine or frontend +request, and a failed read retains the previous bounded slice. +`internal/monitoring/node_memory_sources_test.go` pins both failure continuity +and the within-window no-call path. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index d671119ab..c52f8d27e 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -5570,3 +5570,12 @@ presented indistinguishably from current provider data. `frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts` pins the canonical freshness and error projection, while the row-presentation tests pin the operator-facing state and observation age. + +### Proxmox node network projection does not widen recovery authority + +The shared unified-resource transport now preserves optional configured +Proxmox node interfaces for host-detail presentation. This read-only facet is +not storage inventory, recovery evidence, or an action capability; it grants +no backup, snapshot, restore, retention, cleanup, or verification authority. +The storage/recovery resource query and its admission, freshness, and +persistence contracts are unchanged. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index d90229f6e..80b7b7587 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -4779,3 +4779,19 @@ The drawer header contains no generic Assistant handoff or agent-context copy action. Those shell-level capabilities must not consume object identity space; only the close control and a capability-backed object lifecycle action may sit beside the heading. + +Proxmox node host details include configured interface names and addresses from +the PVE `/nodes/{node}/network` inventory. The secondary network read must not +fail or erase an otherwise healthy node poll: a transient error retains the +last-known interface inventory, while an authoritative empty response clears +it. Cluster connections use the normal endpoint-failover boundary. When a +linked Unified Agent supplies richer interface telemetry, the drawer presents +that agent view once instead of duplicating the PVE inventory. +`internal/monitoring/monitor_pve_cluster_refresh_test.go`, +`internal/monitoring/node_memory_sources_test.go`, +`internal/unifiedresources/adapters_test.go`, +`frontend-modern/src/components/Infrastructure/__tests/ResourceDetailDrawer.identity-runtime.test.tsx`, +`frontend-modern/src/components/Workloads/NodeDrawer.test.tsx`, and +`frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts` +pin collection continuity, canonical projection, and both unified and +Proxmox-page drawer presentation. diff --git a/docs/releases/RELEASE_NOTES_v6.4.0-rc.1.md b/docs/releases/RELEASE_NOTES_v6.4.0-rc.1.md index 5335ced26..6a9454488 100644 --- a/docs/releases/RELEASE_NOTES_v6.4.0-rc.1.md +++ b/docs/releases/RELEASE_NOTES_v6.4.0-rc.1.md @@ -31,6 +31,9 @@ build path. - Platform search and row visibility share the same predicates, including Proxmox nodes and their visible guests. Navigation tabs remain stable across WebSocket updates instead of remounting with live state. +- Proxmox node details show the configured interface names and IPv4/IPv6 + addresses reported by the PVE API, including bridge interfaces, without + requiring a linked Unified Agent. - Workload table columns can be resized at tablet and desktop widths. A resized layout keeps every selected column reachable with horizontal scrolling, persists per surface, and can be shared through the page URL or reset from diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx index 7cc45d1c0..814e3f640 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx @@ -159,6 +159,9 @@ const HostDetailsDisclosure: Component<{ + + + )} diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx index fe2a0782d..16b22f746 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx @@ -355,6 +355,39 @@ describe('ResourceDetailDrawer runtime and identity cards', () => { expect(getByText('eth0')).toBeInTheDocument(); }); + it('shows PVE API network inventory when no linked agent supplies interfaces', async () => { + const resource = baseResource({ + type: 'agent', + platformType: 'proxmox-pve', + sourceType: 'api', + platformData: { + sources: ['proxmox'], + proxmox: { + nodeName: 'pve-1', + pveVersion: '8.4.1', + kernelVersion: '6.8.12', + networkInterfaces: [ + { name: 'eno1', addresses: [] }, + { name: 'vmbr0', addresses: ['192.0.2.10/24', '2001:db8::10/64'] }, + ], + }, + }, + }); + + const { getByRole, getByText, queryByText } = render(() => ( + + )); + + expect(getByText('System, Hardware, Storage, and Network')).toBeInTheDocument(); + expect(queryByText('vmbr0')).toBeNull(); + fireEvent.click(getByRole('button', { name: 'Show host' })); + + await waitFor(() => expect(getByText('vmbr0')).toBeInTheDocument()); + expect(getByText('eno1')).toBeInTheDocument(); + expect(getByText('192.0.2.10/24')).toBeInTheDocument(); + expect(getByText('2001:db8::10/64')).toBeInTheDocument(); + }); + it('surfaces VMware read-only placement and signal context on the shared drawer path', async () => { const resource = baseResource({ type: 'vm', diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerOperationalModel.branchcov2.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerOperationalModel.branchcov2.test.ts index f55fe0b02..cdef3548c 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerOperationalModel.branchcov2.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerOperationalModel.branchcov2.test.ts @@ -201,8 +201,8 @@ describe('buildHostDetailCards branch coverage', () => { ).toEqual([]); }); - it('emits only system/hardware/storage when only the proxmox node flag is set', () => { - // The agent-section counts must be ignored entirely when hasAgentDetails is false. + it('emits Proxmox system/hardware/storage plus provider network inventory', () => { + // Agent-only disk, RAID and temperature counts remain ignored without agent details. expect( buildHostDetailCards({ hasProxmoxNode: true, @@ -212,7 +212,7 @@ describe('buildHostDetailCards branch coverage', () => { raidCount: 9, temperatureRowCount: 9, }), - ).toEqual(['system', 'hardware', 'storage']); + ).toEqual(['system', 'hardware', 'storage', 'network']); }); it('emits system/hardware plus every optional agent section when all counts are positive', () => { diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerOperationalModel.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerOperationalModel.test.ts index 41e68966b..61e4db305 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerOperationalModel.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerOperationalModel.test.ts @@ -118,6 +118,19 @@ describe('resourceDetailDrawerOperationalModel', () => { expect(hasRuntimeOperationalContext([])).toBe(false); }); + it('includes provider network inventory for a Proxmox-only node', () => { + expect( + buildHostDetailCards({ + hasProxmoxNode: true, + hasAgentDetails: false, + networkInterfaceCount: 2, + diskCount: 0, + raidCount: 0, + temperatureRowCount: 0, + }), + ).toEqual(['system', 'hardware', 'storage', 'network']); + }); + it('returns no cross-jump links for docker-host resources after the platform-first migration', () => { // Surface link injection through buildResourceSurfaceLinksForResource was // retired with the old broad cross-surface chip strip; diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerOperationalModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerOperationalModel.ts index 5784ad349..5aaf8e98e 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerOperationalModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerOperationalModel.ts @@ -142,7 +142,11 @@ export const buildHostDetailCards = (options: { if (options.hasAgentDetails) { cards.push('system', 'hardware'); - if (options.networkInterfaceCount > 0) cards.push('network'); + } + + if (options.networkInterfaceCount > 0) cards.push('network'); + + if (options.hasAgentDetails) { if (options.diskCount > 0) cards.push('disks'); if (options.raidCount > 0) cards.push('raid'); if (options.temperatureRowCount > 0) cards.push('temperatures'); diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts index 4cf3cd324..8a0bc6906 100644 --- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts @@ -3,6 +3,7 @@ import { getDiscovery } from '@/api/discovery'; import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery'; import type { ResourceDiscovery, ResourceType as DiscoveryResourceType } from '@/types/discovery'; import type { Resource, ResourceRelationship } from '@/types/resource'; +import type { HostNetworkInterface } from '@/types/api'; import { requiresGovernedResourceDisplay } from '@/types/resource'; import type { ResourceVMwareMeta } from '@/types/resource'; import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format'; @@ -183,6 +184,13 @@ export const useResourceDetailDrawerDerivedState = ( ); const proxmoxNode = createMemo(() => toNodeFromProxmox(resource)); + const proxmoxNetworkInterfaces = createMemo( + () => + resource.proxmox?.networkInterfaces ?? + (platformData()?.proxmox as { networkInterfaces?: HostNetworkInterface[] } | undefined) + ?.networkInterfaces ?? + [], + ); const agentInfo = createMemo(() => toAgentFromResource(resource, agentMeta())); const temperatureRows = createMemo(() => buildTemperatureRows(agentInfo()?.sensors)); const customSensorRows = createMemo(() => buildCustomSensorRows(agentInfo()?.sensors)); @@ -322,7 +330,10 @@ export const useResourceDetailDrawerDerivedState = ( buildHostDetailCards({ hasProxmoxNode: Boolean(proxmoxNode()), hasAgentDetails: Boolean(agentInfo()), - networkInterfaceCount: agentInfo()?.networkInterfaces?.length ?? 0, + networkInterfaceCount: Math.max( + agentInfo()?.networkInterfaces?.length ?? 0, + proxmoxNetworkInterfaces().length, + ), diskCount: agentInfo()?.disks?.length ?? 0, raidCount: agentMeta()?.raid?.length ?? 0, temperatureRowCount: temperatureRows().length + customSensorRows().length, @@ -440,6 +451,7 @@ export const useResourceDetailDrawerDerivedState = ( vmwareData, kubernetesCapabilityBadges, proxmoxNode, + proxmoxNetworkInterfaces, agentInfo, temperatureRows, customSensorRows, diff --git a/frontend-modern/src/components/Workloads/NodeDrawer.test.tsx b/frontend-modern/src/components/Workloads/NodeDrawer.test.tsx index d9eee0a55..5cdd35252 100644 --- a/frontend-modern/src/components/Workloads/NodeDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/NodeDrawer.test.tsx @@ -129,7 +129,19 @@ describe('NodeDrawer', () => { }); it('shows a guest-drawer style Proxmox node overview with detailed node context', () => { - render(() => ); + render(() => ( + + )); const technical = openTechnicalDetails(); expect(screen.getByText('Overview')).toBeInTheDocument(); @@ -139,6 +151,10 @@ describe('NodeDrawer', () => { expect(technical.getAllByText('Platform').length).toBeGreaterThan(0); expect(technical.getByText('Hardware')).toBeInTheDocument(); expect(technical.getByText('Telemetry')).toBeInTheDocument(); + expect(technical.getByText('Network')).toBeInTheDocument(); + expect(technical.getByText('eno1')).toBeInTheDocument(); + expect(technical.getByText('vmbr0')).toBeInTheDocument(); + expect(technical.getByText('192.168.10.21/24 / fd42:7065:6c73::21/64')).toBeInTheDocument(); expect(technical.getByText('Ryzen')).toBeInTheDocument(); expect(technical.getByText('6.8.0')).toBeInTheDocument(); expect(technical.getByText('8')).toBeInTheDocument(); diff --git a/frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx b/frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx index b95f085cc..2376a070f 100644 --- a/frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx +++ b/frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx @@ -217,6 +217,25 @@ export function NodeDrawerOverview(props: NodeDrawerOverviewProps) { { label: 'Root free', value: formatBytes(props.node.disk?.free || 0) }, ]; + const networkRows = (): NodeOverviewRow[] => + (props.node.networkInterfaces || []).reduce( + (rows, networkInterface) => { + const name = cleanText(networkInterface.name); + if (!name) return rows; + const addresses = (networkInterface.addresses || []) + .map((address) => cleanText(address)) + .filter(Boolean); + const mac = cleanText(networkInterface.mac); + rows.push({ + label: name, + value: addresses.join(' / ') || mac || 'Configured', + title: [...addresses, ...(mac ? [mac] : [])].join(' ยท ') || undefined, + }); + return rows; + }, + [], + ); + const telemetryRows = (): NodeOverviewRow[] => [ { label: 'Temp monitor', @@ -235,6 +254,7 @@ export function NodeDrawerOverview(props: NodeDrawerOverviewProps) { { label: 'Platform', rows: toDetailRows(platformRows()) }, { label: 'Hardware', rows: toDetailRows(hardwareRows()) }, { label: 'Memory', rows: toDetailRows(memoryRows()) }, + { label: 'Network', rows: toDetailRows(networkRows()) }, { label: 'Storage', rows: diff --git a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts index 1d7de9785..64160b2d7 100644 --- a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts +++ b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts @@ -1396,6 +1396,42 @@ describe('useUnifiedResources', () => { dispose(); }); + it('projects Proxmox node network interfaces into the resource facet', async () => { + apiFetchMock.mockResolvedValue( + resourceResponse([ + { + ...v2Resource, + sources: ['proxmox'], + proxmox: { + nodeName: 'pve1', + networkInterfaces: [ + { name: 'eno1', addresses: [] }, + { + name: 'vmbr0', + addresses: ['192.168.10.21/24', 'fd42:7065:6c73::21/64'], + }, + ], + }, + }, + ]), + ); + + let result: ReturnType | undefined; + createRoot((dispose) => { + result = useUnifiedResources(); + void dispose; + }); + await waitForResourceCount(() => result?.resources().length ?? 0); + + expect(result!.resources()[0]?.proxmox?.networkInterfaces).toEqual([ + { name: 'eno1', addresses: [] }, + { + name: 'vmbr0', + addresses: ['192.168.10.21/24', 'fd42:7065:6c73::21/64'], + }, + ]); + }); + it('falls back to proxmox temperature when agent temperature is unavailable', async () => { apiFetchMock.mockResolvedValueOnce({ ok: true, diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts index 40a78ff01..2cdce5c46 100644 --- a/frontend-modern/src/hooks/useUnifiedResources.ts +++ b/frontend-modern/src/hooks/useUnifiedResources.ts @@ -244,6 +244,7 @@ type APIResource = { }; template?: boolean; disks?: APIAgentDiskInfo[]; + networkInterfaces?: APIAgentNetworkInterface[]; swapUsed?: number; swapTotal?: number; balloon?: number; @@ -844,6 +845,7 @@ const toResource = (v2: APIResource): Resource => { cpus: v2.proxmox.cpus, template: v2.proxmox.template, disks: normalizeDiskArray(v2.proxmox.disks), + networkInterfaces: v2.proxmox.networkInterfaces, swapUsed: v2.proxmox.swapUsed, swapTotal: v2.proxmox.swapTotal, balloon: v2.proxmox.balloon, diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index 67d0b9131..acaccf131 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -158,6 +158,7 @@ export interface Node { networkOut?: number; diskRead?: number; diskWrite?: number; + networkInterfaces?: HostNetworkInterface[]; uptime: number; loadAverage: number[]; kernelVersion: string; diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index 5566f1d8e..5408d9d6c 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -724,6 +724,7 @@ export interface ResourceProxmoxMeta { diskStatusReason?: string; guestAgentStatus?: string; guestAgentExpected?: boolean; + networkInterfaces?: HostNetworkInterface[]; swapUsed?: number; swapTotal?: number; balloon?: number; diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts index d6c1f6e75..077a3e2e5 100644 --- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts +++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts @@ -61,6 +61,16 @@ function getRuntimeSourceFiles(dir: string): string[] { } describe('docsLinks', () => { + it('keeps restored Proxmox node network details in the candidate packet', () => { + const releaseNotes = readFileSync( + path.join(repoRoot, 'docs', 'releases', 'RELEASE_NOTES_v6.4.0-rc.1.md'), + 'utf8', + ); + expect(releaseNotes).toContain( + 'Proxmox node details show the configured interface names and IPv4/IPv6', + ); + }); + // These now resolve to the in-app viewer route rather than the raw asset. // The raw file is still served at the same path plus .md, which is what the // viewer fetches, so the source remains reachable. diff --git a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts index fe9dae1ea..06a911b2a 100644 --- a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts @@ -178,6 +178,37 @@ describe('resourceStateAdapters nodeFromResource', () => { expect(node?.diskWrite).toBe(8192); }); + it('uses Proxmox network inventory unless a linked agent has richer interface data', () => { + const proxmoxOnly = nodeFromResource({ + ...createNodeResource({}), + proxmox: { + nodeName: 'pve-node-2', + networkInterfaces: [ + { name: 'eno1', addresses: [] }, + { name: 'vmbr0', addresses: ['192.168.10.21/24'] }, + ], + }, + } as Resource); + expect(proxmoxOnly?.networkInterfaces).toEqual([ + { name: 'eno1', addresses: [] }, + { name: 'vmbr0', addresses: ['192.168.10.21/24'] }, + ]); + + const linkedAgent = nodeFromResource({ + ...createNodeResource({}), + proxmox: { + nodeName: 'pve-node-2', + networkInterfaces: [{ name: 'vmbr0', addresses: ['192.168.10.21/24'] }], + }, + agent: { + networkInterfaces: [{ name: 'eth0', addresses: ['192.168.10.22'] }], + }, + } as Resource); + expect(linkedAgent?.networkInterfaces).toEqual([ + { name: 'eth0', addresses: ['192.168.10.22'] }, + ]); + }); + it('maps PBS display and host identity through shared resource helpers', () => { const instance = pbsInstanceFromResource( createServiceResource('pbs', { diff --git a/frontend-modern/src/utils/resourceStateAdapters.ts b/frontend-modern/src/utils/resourceStateAdapters.ts index 5389a7aea..cfb004bda 100644 --- a/frontend-modern/src/utils/resourceStateAdapters.ts +++ b/frontend-modern/src/utils/resourceStateAdapters.ts @@ -1,5 +1,6 @@ import type { Disk, + HostNetworkInterface, Memory, Node, PBSBackupJob, @@ -1279,6 +1280,10 @@ export const nodeFromResource = (resource: Resource): Node | null => { const linkedAgentId = asString(platform?.linkedAgentId) || getActionableAgentIdFromResource(resource); const agentFacet = resource.agent; + const agentNetworkInterfaces = agentFacet?.networkInterfaces; + const proxmoxNetworkInterfaces = Array.isArray(proxmox?.networkInterfaces) + ? (proxmox.networkInterfaces as HostNetworkInterface[]) + : []; const pveVersion = asString(proxmox?.pveVersion) || ((agentFacet?.osName || '').toLowerCase().includes('proxmox') @@ -1311,6 +1316,10 @@ export const nodeFromResource = (resource: Resource): Node | null => { networkOut: resource.network?.txBytes, diskRead: resource.diskIO?.readRate, diskWrite: resource.diskIO?.writeRate, + networkInterfaces: + agentNetworkInterfaces && agentNetworkInterfaces.length > 0 + ? agentNetworkInterfaces + : proxmoxNetworkInterfaces, uptime: resource.uptime ?? asNumber(proxmox?.uptime) ?? 0, loadAverage: asArray(proxmox?.loadAverage) .map((value) => asNumber(value)) diff --git a/internal/mock/generator.go b/internal/mock/generator.go index 9177057f0..09ce9c9c1 100644 --- a/internal/mock/generator.go +++ b/internal/mock/generator.go @@ -1061,8 +1061,12 @@ func generateNode(name string, highLoad bool, config MockConfig) models.Node { MHz: "2400", }, Temperature: temp, - Host: fmt.Sprintf("https://%s.local:8006", name), - ID: "", // Will be set by generateNodes to match real format: instance-nodename + NetworkInterfaces: []models.HostNetworkInterface{ + {Name: "eno1", Addresses: []string{}}, + {Name: "vmbr0", Addresses: []string{"192.168.10.21/24", "fd42:7065:6c73::21/64"}}, + }, + Host: fmt.Sprintf("https://%s.local:8006", name), + ID: "", // Will be set by generateNodes to match real format: instance-nodename } } diff --git a/internal/mock/generator_test.go b/internal/mock/generator_test.go index 0492ebac2..820ad1397 100644 --- a/internal/mock/generator_test.go +++ b/internal/mock/generator_test.go @@ -490,6 +490,19 @@ func TestGenerateNodesBoundsDirectConfiguration(t *testing.T) { } } +func TestGenerateNodeIncludesProxmoxNetworkInventory(t *testing.T) { + node := generateNode("pve1", false, DefaultConfig) + if len(node.NetworkInterfaces) != 2 { + t.Fatalf("network interface count = %d, want 2", len(node.NetworkInterfaces)) + } + if node.NetworkInterfaces[0].Name != "eno1" || node.NetworkInterfaces[1].Name != "vmbr0" { + t.Fatalf("unexpected network interfaces: %+v", node.NetworkInterfaces) + } + if len(node.NetworkInterfaces[1].Addresses) != 2 { + t.Fatalf("bridge addresses = %v, want IPv4 and IPv6", node.NetworkInterfaces[1].Addresses) + } +} + func TestBuildFixtureStateIncludesKubernetesStorageInventory(t *testing.T) { cfg := DefaultConfig cfg.K8sClusterCount = 1 diff --git a/internal/models/deepcopy.go b/internal/models/deepcopy.go index ee50a1516..0a1c12789 100644 --- a/internal/models/deepcopy.go +++ b/internal/models/deepcopy.go @@ -144,6 +144,7 @@ func cloneNode(src Node) Node { dest := src dest.LoadAverage = append([]float64(nil), src.LoadAverage...) dest.NativeNameAliases = append([]string(nil), src.NativeNameAliases...) + dest.NetworkInterfaces = cloneHostNetworkInterfaces(src.NetworkInterfaces) dest.Temperature = cloneTemperature(src.Temperature) dest.TemperatureMonitoringEnabled = cloneBoolPtr(src.TemperatureMonitoringEnabled) return dest.NormalizeCollections() diff --git a/internal/models/metrics_types_test.go b/internal/models/metrics_types_test.go index b180fb25d..c098fe8ec 100644 --- a/internal/models/metrics_types_test.go +++ b/internal/models/metrics_types_test.go @@ -19,6 +19,21 @@ func TestMetricPoint_ZeroValue(t *testing.T) { } } +func TestNodeNetworkInterfacesNormalizeCollections(t *testing.T) { + node := Node{NetworkInterfaces: []HostNetworkInterface{{Name: "vmbr0"}}}.NormalizeCollections() + if node.NetworkInterfaces == nil || node.NetworkInterfaces[0].Addresses == nil { + t.Fatalf("node network interface collections must be non-nil: %#v", node.NetworkInterfaces) + } + + payload, err := json.Marshal(node) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + if !strings.Contains(string(payload), `"networkInterfaces":[{"name":"vmbr0","addresses":[]}]`) { + t.Fatalf("node JSON omitted normalized network interfaces: %s", payload) + } +} + func TestIOMetrics_Fields(t *testing.T) { now := time.Now() metrics := IOMetrics{ diff --git a/internal/models/models.go b/internal/models/models.go index 16fe1eca4..a7f94be6a 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -118,31 +118,32 @@ type ResolvedAlert struct { // Node represents a Proxmox VE node type Node struct { - ID string `json:"id"` - NodeIdentity string `json:"nodeIdentity,omitempty"` - Name string `json:"name"` - NativeNameAliases []string `json:"nativeNameAliases,omitempty"` - DisplayName string `json:"displayName,omitempty"` - Instance string `json:"instance"` - Host string `json:"host"` // Full host URL from config - GuestURL string `json:"guestURL"` // Optional guest-accessible URL (for navigation) - Status string `json:"status"` - Type string `json:"type"` - CPU float64 `json:"cpu"` - Memory Memory `json:"memory"` - Disk Disk `json:"disk"` - Uptime int64 `json:"uptime"` - LoadAverage []float64 `json:"loadAverage"` - KernelVersion string `json:"kernelVersion"` - PVEVersion string `json:"pveVersion"` - CPUInfo CPUInfo `json:"cpuInfo"` - Temperature *Temperature `json:"temperature,omitempty"` // CPU/NVMe temperatures - TemperatureMonitoringEnabled *bool `json:"temperatureMonitoringEnabled,omitempty"` // Per-node temperature monitoring override - LastSeen time.Time `json:"lastSeen"` - ConnectionHealth string `json:"connectionHealth"` - IsClusterMember bool `json:"isClusterMember"` // True if part of a cluster - ClusterName string `json:"clusterName"` // Name of cluster (empty if standalone) - ProviderScopedIdentity bool `json:"providerScopedIdentity,omitempty"` + ID string `json:"id"` + NodeIdentity string `json:"nodeIdentity,omitempty"` + Name string `json:"name"` + NativeNameAliases []string `json:"nativeNameAliases,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Instance string `json:"instance"` + Host string `json:"host"` // Full host URL from config + GuestURL string `json:"guestURL"` // Optional guest-accessible URL (for navigation) + Status string `json:"status"` + Type string `json:"type"` + CPU float64 `json:"cpu"` + Memory Memory `json:"memory"` + Disk Disk `json:"disk"` + Uptime int64 `json:"uptime"` + LoadAverage []float64 `json:"loadAverage"` + KernelVersion string `json:"kernelVersion"` + PVEVersion string `json:"pveVersion"` + CPUInfo CPUInfo `json:"cpuInfo"` + NetworkInterfaces []HostNetworkInterface `json:"networkInterfaces,omitempty"` + Temperature *Temperature `json:"temperature,omitempty"` // CPU/NVMe temperatures + TemperatureMonitoringEnabled *bool `json:"temperatureMonitoringEnabled,omitempty"` // Per-node temperature monitoring override + LastSeen time.Time `json:"lastSeen"` + ConnectionHealth string `json:"connectionHealth"` + IsClusterMember bool `json:"isClusterMember"` // True if part of a cluster + ClusterName string `json:"clusterName"` // Name of cluster (empty if standalone) + ProviderScopedIdentity bool `json:"providerScopedIdentity,omitempty"` // TLSFingerprint is the TOFU-captured TLS certificate fingerprint recorded // in configuration for this node's API endpoint, when known. It is machine // identity evidence for state aggregation only, never a polling or trust @@ -161,6 +162,12 @@ func (n Node) NormalizeCollections() Node { if n.LoadAverage == nil { n.LoadAverage = []float64{} } + if n.NetworkInterfaces == nil { + n.NetworkInterfaces = []HostNetworkInterface{} + } + for i := range n.NetworkInterfaces { + n.NetworkInterfaces[i] = n.NetworkInterfaces[i].NormalizeCollections() + } if n.Temperature != nil { temp := n.Temperature.NormalizeCollections() n.Temperature = &temp diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 0bc9fe44a..2032c0e49 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -3379,6 +3379,7 @@ func nodeFromReadStateView(view *unifiedresources.NodeView) models.Node { KernelVersion: view.KernelVersion(), PVEVersion: view.PVEVersion(), CPUInfo: view.CPUInfo(), + NetworkInterfaces: hostNetworkInterfacesFromReadStateView(view.NetworkInterfaces()), Temperature: view.TemperatureDetails(), TemperatureMonitoringEnabled: view.TemperatureMonitoringEnabled(), LastSeen: view.LastSeen(), diff --git a/internal/monitoring/monitor_polling_node.go b/internal/monitoring/monitor_polling_node.go index eb640ba4b..035897cdc 100644 --- a/internal/monitoring/monitor_polling_node.go +++ b/internal/monitoring/monitor_polling_node.go @@ -2,7 +2,9 @@ package monitoring import ( "context" + "sort" "strconv" + "strings" "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" @@ -11,7 +13,88 @@ import ( "github.com/rs/zerolog/log" ) -const nodeMemoryCarryForwardMaxAge = 2 * time.Minute +const ( + nodeMemoryCarryForwardMaxAge = 2 * time.Minute + pveNodeNetworkRefreshInterval = 5 * time.Minute +) + +type pveNodeNetworkGetter interface { + GetNodeNetworkInterfaces(context.Context, string) ([]proxmox.NodeNetworkInterface, error) +} + +func previousNodeNetworkInterfaces(previous []models.Node, instance, nodeID, nodeName string) ([]models.HostNetworkInterface, time.Time, bool) { + for _, candidate := range previous { + if strings.TrimSpace(candidate.Instance) != strings.TrimSpace(instance) { + continue + } + if strings.TrimSpace(candidate.ID) == strings.TrimSpace(nodeID) || + strings.EqualFold(strings.TrimSpace(candidate.Name), strings.TrimSpace(nodeName)) { + interfaces := make([]models.HostNetworkInterface, len(candidate.NetworkInterfaces)) + for i, iface := range candidate.NetworkInterfaces { + interfaces[i] = iface + interfaces[i].Addresses = append([]string(nil), iface.Addresses...) + } + return interfaces, candidate.LastSeen, true + } + } + return nil, time.Time{}, false +} + +func mapPVENodeNetworkInterfaces(interfaces []proxmox.NodeNetworkInterface) []models.HostNetworkInterface { + mapped := make([]models.HostNetworkInterface, 0, len(interfaces)) + for _, iface := range interfaces { + name := strings.TrimSpace(iface.Iface) + if name == "" { + continue + } + addresses := make([]string, 0, 2) + seen := make(map[string]struct{}, 2) + ipv4 := strings.TrimSpace(iface.CIDR) + if ipv4 == "" { + ipv4 = strings.TrimSpace(iface.Address) + } + for _, address := range []string{ipv4, iface.Address6} { + address = strings.TrimSpace(address) + if address == "" { + continue + } + if _, ok := seen[address]; ok { + continue + } + seen[address] = struct{}{} + addresses = append(addresses, address) + } + mapped = append(mapped, models.HostNetworkInterface{Name: name, Addresses: addresses}) + } + sort.SliceStable(mapped, func(i, j int) bool { + return strings.ToLower(mapped[i].Name) < strings.ToLower(mapped[j].Name) + }) + return mapped +} + +func collectPVENodeNetworkInterfaces( + ctx context.Context, + client PVEClientInterface, + instance, nodeID, nodeName string, + previous []models.Node, +) []models.HostNetworkInterface { + fallback, previousPollAt, foundPrevious := previousNodeNetworkInterfaces(previous, instance, nodeID, nodeName) + if foundPrevious && !previousPollAt.IsZero() && + previousPollAt.Truncate(pveNodeNetworkRefreshInterval) == time.Now().Truncate(pveNodeNetworkRefreshInterval) { + return fallback + } + getter, ok := client.(pveNodeNetworkGetter) + if !ok { + return fallback + } + interfaces, err := getter.GetNodeNetworkInterfaces(ctx, nodeName) + if err != nil { + log.Debug().Err(err).Str("instance", instance).Str("node", nodeName). + Msg("Could not refresh Proxmox node network interfaces; retaining last-known inventory") + return fallback + } + return mapPVENodeNetworkInterfaces(interfaces) +} func (m *Monitor) canCarryForwardNodeMemory(instance, node string, now time.Time) bool { if m == nil { @@ -220,6 +303,16 @@ func (m *Monitor) pollPVENode( } } + if effectiveStatus == "online" { + modelNode.NetworkInterfaces = collectPVENodeNetworkInterfaces( + ctx, client, instanceName, nodeID, node.Node, prevInstanceNodes, + ) + } else { + modelNode.NetworkInterfaces, _, _ = previousNodeNetworkInterfaces( + prevInstanceNodes, instanceName, nodeID, node.Node, + ) + } + // If we couldn't update memory metrics using detailed status, preserve previous accurate values if available if !memoryUpdated && effectiveStatus == "online" && diff --git a/internal/monitoring/monitor_previous_state.go b/internal/monitoring/monitor_previous_state.go index 914198f94..9aa8340d3 100644 --- a/internal/monitoring/monitor_previous_state.go +++ b/internal/monitoring/monitor_previous_state.go @@ -188,18 +188,19 @@ func previousNodeFromView(node *unifiedresources.NodeView) models.Node { return models.Node{} } return models.Node{ - ID: node.ID(), - Name: node.NodeName(), - DisplayName: node.Name(), - Instance: node.Instance(), - Host: node.HostURL(), - Status: string(node.Status()), - Uptime: node.Uptime(), - IsClusterMember: node.IsClusterMember(), - LastSeen: node.LastSeen(), - LoadAverage: node.LoadAverage(), - PVEVersion: node.PVEVersion(), - KernelVersion: node.KernelVersion(), + ID: node.ID(), + Name: node.NodeName(), + DisplayName: node.Name(), + Instance: node.Instance(), + Host: node.HostURL(), + Status: string(node.Status()), + Uptime: node.Uptime(), + IsClusterMember: node.IsClusterMember(), + LastSeen: node.LastSeen(), + LoadAverage: node.LoadAverage(), + PVEVersion: node.PVEVersion(), + KernelVersion: node.KernelVersion(), + NetworkInterfaces: hostNetworkInterfacesFromReadStateView(node.NetworkInterfaces()), Memory: models.Memory{ Used: node.MemoryUsed(), Total: node.MemoryTotal(), diff --git a/internal/monitoring/monitor_pve_cluster_refresh_test.go b/internal/monitoring/monitor_pve_cluster_refresh_test.go index 430e806bc..4305732a3 100644 --- a/internal/monitoring/monitor_pve_cluster_refresh_test.go +++ b/internal/monitoring/monitor_pve_cluster_refresh_test.go @@ -3,6 +3,7 @@ package monitoring import ( "context" "fmt" + "reflect" "testing" "time" @@ -11,6 +12,21 @@ import ( "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" ) +func TestMapPVENodeNetworkInterfacesKeepsConfiguredBridgesAndAddresses(t *testing.T) { + got := mapPVENodeNetworkInterfaces([]proxmox.NodeNetworkInterface{ + {Iface: "vmbr0", Type: "bridge", CIDR: "192.0.2.10/24", Address: "192.0.2.10", Address6: "2001:db8::10/64", Active: 1}, + {Iface: " eno1 ", Type: "eth", Active: 1}, + {Iface: "", Address: "198.51.100.1"}, + }) + want := []models.HostNetworkInterface{ + {Name: "eno1", Addresses: []string{}}, + {Name: "vmbr0", Addresses: []string{"192.0.2.10/24", "2001:db8::10/64"}}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("network interfaces = %#v, want %#v", got, want) + } +} + // Refreshing an existing cluster's endpoints when the cluster re-IPs (#1493): // the stored add-time addresses must be replaced with what the cluster // reports now, and the failover client must be rebuilt so polling stops diff --git a/internal/monitoring/node_memory_sources_test.go b/internal/monitoring/node_memory_sources_test.go index 9a4b9267a..70687c67d 100644 --- a/internal/monitoring/node_memory_sources_test.go +++ b/internal/monitoring/node_memory_sources_test.go @@ -2,13 +2,63 @@ package monitoring import ( "context" + "errors" + "reflect" "testing" + "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" ) +type nodeNetworkFailureClient struct { + PVEClientInterface + err error + calls *int +} + +func (c nodeNetworkFailureClient) GetNodeNetworkInterfaces(context.Context, string) ([]proxmox.NodeNetworkInterface, error) { + if c.calls != nil { + *c.calls++ + } + return nil, c.err +} + +func TestPVENodeNetworkFailurePreservesLastKnownInventory(t *testing.T) { + previous := []models.Node{{ + ID: "lab-pve1", Instance: "lab", Name: "pve1", + NetworkInterfaces: []models.HostNetworkInterface{{Name: "vmbr0", Addresses: []string{"192.0.2.10/24"}}}, + }} + client := nodeNetworkFailureClient{err: errors.New("temporary network inventory failure")} + + got := collectPVENodeNetworkInterfaces(context.Background(), client, "lab", "lab-pve1", "pve1", previous) + if !reflect.DeepEqual(got, previous[0].NetworkInterfaces) { + t.Fatalf("network interfaces = %#v, want previous %#v", got, previous[0].NetworkInterfaces) + } + got[0].Addresses[0] = "mutated" + if previous[0].NetworkInterfaces[0].Addresses[0] != "192.0.2.10/24" { + t.Fatal("last-known interface fallback aliases previous state") + } +} + +func TestPVENodeNetworkRefreshIsBoundedWithinPollingWindow(t *testing.T) { + calls := 0 + previous := []models.Node{{ + ID: "lab-pve1", Instance: "lab", Name: "pve1", LastSeen: time.Now(), + NetworkInterfaces: []models.HostNetworkInterface{{Name: "vmbr0", Addresses: []string{"192.0.2.10/24"}}}, + }} + client := nodeNetworkFailureClient{err: errors.New("must not be called"), calls: &calls} + + got := collectPVENodeNetworkInterfaces(context.Background(), client, "lab", "lab-pve1", "pve1", previous) + if calls != 0 { + t.Fatalf("network inventory calls = %d, want 0 within refresh window", calls) + } + if !reflect.DeepEqual(got, previous[0].NetworkInterfaces) { + t.Fatalf("network interfaces = %#v, want previous %#v", got, previous[0].NetworkInterfaces) + } +} + func TestResolveNodeMemoryCharacterization(t *testing.T) { t.Setenv("PULSE_DATA_DIR", t.TempDir()) diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index 209971e60..5be80ef47 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -69,6 +69,7 @@ func resourceFromProxmoxNode(node models.Node, linkedHost *models.Host) (Resourc Uptime: node.Uptime, CPUInfo: &CPUInfo{Model: node.CPUInfo.Model, Cores: node.CPUInfo.Cores, Sockets: node.CPUInfo.Sockets}, LoadAverage: append([]float64(nil), node.LoadAverage...), + NetworkInterfaces: convertInterfaces(node.NetworkInterfaces), PendingUpdates: node.PendingUpdates, TemperatureMonitoringEnabled: cloneBoolPtr(node.TemperatureMonitoringEnabled), PendingUpdatesCheckedAt: zeroTimeToPtr(node.PendingUpdatesCheckedAt), diff --git a/internal/unifiedresources/adapters_test.go b/internal/unifiedresources/adapters_test.go index 1143cebf6..054dea4b1 100644 --- a/internal/unifiedresources/adapters_test.go +++ b/internal/unifiedresources/adapters_test.go @@ -41,6 +41,23 @@ func TestResourceFromProxmoxNodeIncludesTemperature(t *testing.T) { } } +func TestResourceFromProxmoxNodeIncludesNetworkInterfaces(t *testing.T) { + node := models.Node{ + ID: "mock-cluster-pve1", Name: "pve1", Status: "online", + NetworkInterfaces: []models.HostNetworkInterface{{ + Name: "vmbr0", Addresses: []string{"192.0.2.10/24"}, + }}, + } + + resource, _ := resourceFromProxmoxNode(node, nil) + if resource.Proxmox == nil || len(resource.Proxmox.NetworkInterfaces) != 1 { + t.Fatalf("expected proxmox network interface projection, got %+v", resource.Proxmox) + } + if got := resource.Proxmox.NetworkInterfaces[0]; got.Name != "vmbr0" || !reflect.DeepEqual(got.Addresses, []string{"192.0.2.10/24"}) { + t.Fatalf("network interface = %+v", got) + } +} + func TestResourceFromHostProjectsXCPNGPoolIdentity(t *testing.T) { host := models.Host{ ID: "agent-one", diff --git a/internal/unifiedresources/views.go b/internal/unifiedresources/views.go index 43f90fd44..5c93bd90f 100644 --- a/internal/unifiedresources/views.go +++ b/internal/unifiedresources/views.go @@ -824,6 +824,13 @@ func (v NodeView) LoadAverage() []float64 { return cloneFloat64Slice(v.r.Proxmox.LoadAverage) } +func (v NodeView) NetworkInterfaces() []NetworkInterface { + if v.r == nil || v.r.Proxmox == nil { + return nil + } + return cloneNetworkInterfaces(v.r.Proxmox.NetworkInterfaces) +} + func (v NodeView) PendingUpdates() int { if v.r == nil || v.r.Proxmox == nil { return 0 diff --git a/pkg/proxmox/cluster_client.go b/pkg/proxmox/cluster_client.go index cb7db906b..eb451db22 100644 --- a/pkg/proxmox/cluster_client.go +++ b/pkg/proxmox/cluster_client.go @@ -1111,6 +1111,21 @@ func (cc *ClusterClient) GetNodeStatus(ctx context.Context, node string) (*NodeS return result, err } +// GetNodeNetworkInterfaces returns the configured interfaces for a node using +// the same cluster failover boundary as the rest of the node inventory. +func (cc *ClusterClient) GetNodeNetworkInterfaces(ctx context.Context, node string) ([]NodeNetworkInterface, error) { + var result []NodeNetworkInterface + err := cc.executeWithFailover(ctx, func(client *Client) error { + interfaces, err := client.GetNodeNetworkInterfaces(ctx, node) + if err != nil { + return err + } + result = interfaces + return nil + }) + return result, err +} + func (cc *ClusterClient) GetNodeRRDData(ctx context.Context, node, timeframe, cf string, ds []string) ([]NodeRRDPoint, error) { var result []NodeRRDPoint err := cc.executeWithFailover(ctx, func(client *Client) error {