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 {