Restore Proxmox node network details

Refs #1716

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-08-26 15:17:12 +01:00
parent 9e9c3413a8
commit 75b6655d6e
37 changed files with 565 additions and 46 deletions

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -159,6 +159,9 @@ const HostDetailsDisclosure: Component<{
<SystemInfoCard variant="node" node={node()} />
<HardwareCard variant="node" node={node()} />
<RootDiskCard node={node()} />
<Show when={!props.drawer.agentInfo()?.networkInterfaces?.length}>
<NetworkInterfacesCard interfaces={props.drawer.proxmoxNetworkInterfaces()} />
</Show>
</>
)}
</Show>

View file

@ -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(() => (
<ResourceDetailDrawer resource={resource} />
));
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',

View file

@ -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', () => {

View file

@ -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;

View file

@ -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');

View file

@ -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,

View file

@ -129,7 +129,19 @@ describe('NodeDrawer', () => {
});
it('shows a guest-drawer style Proxmox node overview with detailed node context', () => {
render(() => <NodeDrawer node={makeNode()} />);
render(() => (
<NodeDrawer
node={makeNode({
networkInterfaces: [
{ name: 'eno1', addresses: [] },
{
name: 'vmbr0',
addresses: ['192.168.10.21/24', 'fd42:7065:6c73::21/64'],
},
],
})}
/>
));
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();

View file

@ -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<NodeOverviewRow[]>(
(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:

View file

@ -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<typeof useUnifiedResources> | 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,

View file

@ -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,

View file

@ -158,6 +158,7 @@ export interface Node {
networkOut?: number;
diskRead?: number;
diskWrite?: number;
networkInterfaces?: HostNetworkInterface[];
uptime: number;
loadAverage: number[];
kernelVersion: string;

View file

@ -724,6 +724,7 @@ export interface ResourceProxmoxMeta {
diskStatusReason?: string;
guestAgentStatus?: string;
guestAgentExpected?: boolean;
networkInterfaces?: HostNetworkInterface[];
swapUsed?: number;
swapTotal?: number;
balloon?: number;

View file

@ -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.

View file

@ -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', {

View file

@ -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))

View file

@ -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
}
}

View file

@ -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

View file

@ -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()

View file

@ -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{

View file

@ -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

View file

@ -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(),

View file

@ -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" &&

View file

@ -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(),

View file

@ -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

View file

@ -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())

View file

@ -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),

View file

@ -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",

View file

@ -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

View file

@ -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 {