mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
Restore Proxmox node network details
Refs #1716 Change-source: pulse-maintainer
This commit is contained in:
parent
9e9c3413a8
commit
75b6655d6e
37 changed files with 565 additions and 46 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ export interface Node {
|
|||
networkOut?: number;
|
||||
diskRead?: number;
|
||||
diskWrite?: number;
|
||||
networkInterfaces?: HostNetworkInterface[];
|
||||
uptime: number;
|
||||
loadAverage: number[];
|
||||
kernelVersion: string;
|
||||
|
|
|
|||
|
|
@ -724,6 +724,7 @@ export interface ResourceProxmoxMeta {
|
|||
diskStatusReason?: string;
|
||||
guestAgentStatus?: string;
|
||||
guestAgentExpected?: boolean;
|
||||
networkInterfaces?: HostNetworkInterface[];
|
||||
swapUsed?: number;
|
||||
swapTotal?: number;
|
||||
balloon?: number;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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" &&
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue