Merge release/v3.6.1 into main

This commit is contained in:
courtmanr@gmail.com
2025-05-10 16:11:33 +01:00
7 changed files with 368 additions and 215 deletions
+57 -2
View File
@@ -53,7 +53,60 @@ async function fetchDataForNode(apiClient, endpointId, nodeName) {
*/
async function fetchDataForPveEndpoint(endpointId, apiClientInstance, config) {
const endpointName = config.name || endpointId; // Use configured name or ID
let endpointType = 'standalone'; // Default to standalone
let actualClusterName = config.name || endpointId; // Default identifier to endpoint name
let standaloneNodeName = null; // To store the name of the standalone node if applicable
try {
// Attempt to determine if the endpoint is part of a multi-node cluster
try {
const clusterStatusResponse = await apiClientInstance.get('/cluster/status');
if (clusterStatusResponse.data && Array.isArray(clusterStatusResponse.data.data) && clusterStatusResponse.data.data.length > 0) {
const clusterInfoObject = clusterStatusResponse.data.data.find(item => item.type === 'cluster');
if (clusterInfoObject) {
if (clusterInfoObject.nodes && clusterInfoObject.nodes > 1) {
endpointType = 'cluster';
actualClusterName = clusterInfoObject.name || actualClusterName; // Use actual cluster name if available
} else {
endpointType = 'standalone'; // Still standalone if nodes <= 1
// Attempt to get the actual node name for the label if it's standalone
const nodesListForEndpoint = (await apiClientInstance.get('/nodes')).data?.data;
if (nodesListForEndpoint && nodesListForEndpoint.length > 0) {
// For a standalone or single-node cluster, use its own node name as the identifier
standaloneNodeName = nodesListForEndpoint[0].node;
}
}
} else {
endpointType = 'standalone';
const nodesListForEndpoint = (await apiClientInstance.get('/nodes')).data?.data;
if (nodesListForEndpoint && nodesListForEndpoint.length > 0) {
standaloneNodeName = nodesListForEndpoint[0].node;
}
}
} else {
console.warn(`[DataFetcher - ${endpointName}] No nodes found or unexpected format.`);
endpointType = 'standalone'; // Fallback
}
} catch (clusterError) {
console.error(`[DataFetcher - ${endpointName}] Error fetching /cluster/status: ${clusterError.message}`, clusterError);
endpointType = 'standalone'; // Fallback
// Even on /cluster/status error, try to get node name if it's likely standalone
try {
const nodesListForEndpoint = (await apiClientInstance.get('/nodes')).data?.data;
if (nodesListForEndpoint && nodesListForEndpoint.length > 0) {
standaloneNodeName = nodesListForEndpoint[0].node;
}
} catch (nodesError) {
console.error(`[DataFetcher - ${endpointName}] Also failed to fetch /nodes after /cluster/status error: ${nodesError.message}`);
}
}
// Update actualClusterName if this is a standalone endpoint and we found a specific node name
if (endpointType === 'standalone' && standaloneNodeName) {
actualClusterName = standaloneNodeName;
}
const nodesResponse = await apiClientInstance.get('/nodes');
const nodes = nodesResponse.data.data;
if (!nodes || !Array.isArray(nodes)) {
@@ -82,6 +135,8 @@ async function fetchDataForPveEndpoint(endpointId, apiClientInstance, config) {
status: correspondingNodeInfo.status || 'unknown',
id: `${endpointId}-${correspondingNodeInfo.node}`, // Use endpointId for node ID
endpointId: endpointId, // Use endpointId for tagging node
clusterIdentifier: actualClusterName, // Use actual cluster name or endpoint name
endpointType: endpointType, // Added to differentiate cluster vs standalone for labeling
};
if (result.status === 'fulfilled' && result.value) {
@@ -106,9 +161,9 @@ async function fetchDataForPveEndpoint(endpointId, apiClientInstance, config) {
processedNodes.push(finalNode);
} else {
if (result.status === 'rejected') {
console.error(`[DataFetcher - ${endpointName}] Error processing node ${correspondingNodeInfo.node}: ${result.reason?.message || result.reason}`);
console.error(`[DataFetcher - ${endpointName}-${correspondingNodeInfo.node}] Error fetching Node status: ${result.reason?.message || result.reason}`);
} else {
console.warn(`[DataFetcher - ${endpointName}] Unexpected result status for node ${correspondingNodeInfo.node}: ${result.status}`);
console.warn(`[DataFetcher - ${endpointName}-${correspondingNodeInfo.node}] Unexpected result status: ${result.status}`);
}
processedNodes.push(finalNode); // Push node with defaults on failure
}
+236 -180
View File
@@ -111,22 +111,28 @@ describe('Data Fetcher', () => {
// Configure mockPveClientInstance.get
mockPveClientInstance.get
.mockResolvedValueOnce({ data: { data: [{ node: nodeName, status: 'online', maxcpu: 4, maxmem: 8 * 1024**3 , id: `node/${nodeName}` }] } }) // /nodes
.mockResolvedValueOnce({ data: { data: { cpu: 0.1, mem: 2 * 1024**3, rootfs: { total: 100*1024**3, used: 20*1024**3 }, uptime: 12345 } } }) // status
.mockResolvedValueOnce({ data: { data: [ { storage: 'local-lvm', type: 'lvmthin', content: 'images,rootdir', total: 500*1024**3, used: 150*1024**3 } ] } }) // storage
.mockResolvedValueOnce({ data: { data: [ { vmid: vmId, name: 'test-vm', status: 'running', cpu: 0.5, mem: 1 * 1024**3, maxmem: 2 * 1024**3, maxdisk: 32*1024**3 } ] } }) // qemu
.mockResolvedValueOnce({ data: { data: [ { vmid: ctId, name: 'test-ct', status: 'running', cpu: 0.2, mem: 512 * 1024**2, maxmem: 1 * 1024**3, maxdisk: 8*1024**3 } ] } }); // lxc
// Calls within fetchDataForPveEndpoint for 'primary'
.mockResolvedValueOnce({ data: { data: [{ type: 'cluster', nodes: 1, name: 'test-cluster' }] } }) // 1. /cluster/status (for endpoint 'primary')
.mockResolvedValueOnce({ data: { data: [{ node: nodeName, status: 'online' }] } }) // 2. /nodes (for endpoint 'primary' to get standaloneNodeName if cluster nodes <=1)
.mockResolvedValueOnce({ data: { data: [{ node: nodeName, status: 'online', maxcpu: 4, maxmem: 8 * 1024**3 , id: `node/${nodeName}` }] } }) // 3. /nodes (main call for endpoint 'primary' to get node list)
// Calls within fetchDataForNode for 'mock-node' (the single node from the call above)
.mockResolvedValueOnce({ data: { data: { cpu: 0.1, mem: 2 * 1024**3, rootfs: { total: 100*1024**3, used: 20*1024**3 }, uptime: 12345 } } }) // 4. /nodes/mock-node/status
.mockResolvedValueOnce({ data: { data: [ { storage: 'local-lvm', type: 'lvmthin', content: 'images,rootdir', total: 500*1024**3, used: 150*1024**3 } ] } }) // 5. /nodes/mock-node/storage
.mockResolvedValueOnce({ data: { data: [ { vmid: vmId, name: 'test-vm', status: 'running', cpu: 0.5, mem: 1 * 1024**3, maxmem: 2 * 1024**3, maxdisk: 32*1024**3 } ] } }) // 6. /nodes/mock-node/qemu
.mockResolvedValueOnce({ data: { data: [ { vmid: ctId, name: 'test-ct', status: 'running', cpu: 0.2, mem: 512 * 1024**2, maxmem: 1 * 1024**3, maxdisk: 8*1024**3 } ] } }); // 7. /nodes/mock-node/lxc
// Act: Call function with the clients provided by the (mocked) default setup
const result = await fetchDiscoveryData(mockPveApiClient, mockPbsApiClient);
// Assert
expect(mockPveClientInstance.get).toHaveBeenCalledTimes(5);
expect(mockPveClientInstance.get).toHaveBeenNthCalledWith(1, '/nodes');
expect(mockPveClientInstance.get).toHaveBeenNthCalledWith(2, `/nodes/${nodeName}/status`);
expect(mockPveClientInstance.get).toHaveBeenNthCalledWith(3, `/nodes/${nodeName}/storage`);
expect(mockPveClientInstance.get).toHaveBeenNthCalledWith(4, `/nodes/${nodeName}/qemu`);
expect(mockPveClientInstance.get).toHaveBeenNthCalledWith(5, `/nodes/${nodeName}/lxc`);
expect(mockPveClientInstance.get).toHaveBeenCalledTimes(7); // Updated to 7
expect(mockPveClientInstance.get).toHaveBeenNthCalledWith(1, '/cluster/status');
expect(mockPveClientInstance.get).toHaveBeenNthCalledWith(2, '/nodes'); // For standaloneNodeName
expect(mockPveClientInstance.get).toHaveBeenNthCalledWith(3, '/nodes'); // Main nodes call
expect(mockPveClientInstance.get).toHaveBeenNthCalledWith(4, `/nodes/${nodeName}/status`);
expect(mockPveClientInstance.get).toHaveBeenNthCalledWith(5, `/nodes/${nodeName}/storage`);
expect(mockPveClientInstance.get).toHaveBeenNthCalledWith(6, `/nodes/${nodeName}/qemu`);
expect(mockPveClientInstance.get).toHaveBeenNthCalledWith(7, `/nodes/${nodeName}/lxc`);
// Assert Nodes
expect(result.nodes).toHaveLength(1);
@@ -178,23 +184,26 @@ describe('Data Fetcher', () => {
test('should handle invalid format for node storage response', async () => {
// Arrange: Use default mock client
const nodeName = 'node-bad-storage-format';
const endpointId = 'primary';
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
mockPveClientInstance.get
.mockResolvedValueOnce({ data: { data: [{ node: nodeName, status: 'online' }] } }) // /nodes
.mockResolvedValueOnce({ data: { data: { cpu: 0.1, uptime: 10 } } }) // status
// ** Invalid storage format **
.mockResolvedValueOnce({ data: { data: { not_an_array: true } } }) // storage
.mockResolvedValueOnce({ data: { data: [] } }) // qemu (empty)
.mockResolvedValueOnce({ data: { data: [] } }); // lxc (empty)
// Endpoint level calls
.mockResolvedValueOnce({ data: { data: [{ type: 'cluster', nodes: 1, name: 'test-cluster' }] } }) // 1. /cluster/status
.mockResolvedValueOnce({ data: { data: [{ node: nodeName }] } }) // 2. /nodes (for standaloneNodeName)
.mockResolvedValueOnce({ data: { data: [{ node: nodeName, status: 'online' }] } }) // 3. /nodes (main node list for endpoint)
// Node level calls for nodeName
.mockResolvedValueOnce({ data: { data: { cpu: 0.1, uptime: 10 } } }) // 4. /nodes/${nodeName}/status
.mockResolvedValueOnce({ data: { data: { not_an_array: true } } }) // 5. /nodes/${nodeName}/storage (INVALID)
.mockResolvedValueOnce({ data: { data: [] } }) // 6. /nodes/${nodeName}/qemu
.mockResolvedValueOnce({ data: { data: [] } }); // 7. /nodes/${nodeName}/lxc
// Act
const result = await fetchDiscoveryData(mockPveApiClient, mockPbsApiClient);
// Assert
expect(consoleWarnSpy).toHaveBeenCalledWith(
// Adjust expected message based on actual output
expect.stringContaining(`[DataFetcher - primary-${nodeName}] Node storage data is not an array as expected.`)
expect.stringContaining(`[DataFetcher - ${endpointId}-${nodeName}] Node storage data is not an array as expected.`)
);
// Ensure node was still processed and added (just without storage)
expect(result.nodes).toHaveLength(1);
@@ -208,34 +217,34 @@ describe('Data Fetcher', () => {
test('should handle missing or invalid data.data for a node resource', async () => {
// Arrange: Use default mock client and a specific node name
const nodeName = 'node-missing-data-data';
const nodeName = 'node-missing-data-data'; // Can be the same or different, impact is on the mock
const endpointId = 'primary';
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
// Mock the /nodes call to return the node
mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: [{ node: nodeName, status: 'online' }] } }); // /nodes
// Mock the /status call to return data with null data.data (covers lines 108-113)
mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: null } }); // status
// Mock other calls to succeed with empty data to allow the test to proceed
mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: [] } }); // storage
mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: [] } }); // qemu
mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: [] } }); // lxc
mockPveClientInstance.get
// Endpoint level calls
.mockResolvedValueOnce({ data: { data: [{ type: 'cluster', nodes: 1, name: 'test-cluster' }] } }) // 1. /cluster/status
.mockResolvedValueOnce({ data: { data: [{ node: nodeName }] } }) // 2. /nodes (for standaloneNodeName)
.mockResolvedValueOnce({ data: { data: [{ node: nodeName, status: 'online' }] } }) // 3. /nodes (main node list for endpoint)
// Node level calls for nodeName
.mockResolvedValueOnce({ data: { data: null } }) // 4. /nodes/${nodeName}/status (INVALID data.data)
.mockResolvedValueOnce({ data: { data: [] } }) // 5. /nodes/${nodeName}/storage
.mockResolvedValueOnce({ data: { data: [] } }) // 6. /nodes/${nodeName}/qemu
.mockResolvedValueOnce({ data: { data: [] } }); // 7. /nodes/${nodeName}/lxc
// Act
const result = await fetchDiscoveryData(mockPveApiClient, mockPbsApiClient);
// Assert
// Verify the warning was logged by fetchNodeResource (covers line 109)
// Verify the warning was logged by fetchNodeResource
expect(consoleWarnSpy).toHaveBeenCalledWith(
`[DataFetcher - primary-${nodeName}] Node status data missing or invalid format.`
`[DataFetcher - ${endpointId}-${nodeName}] Node status data missing or invalid format.`
);
// Verify that the node was still processed but status data is default (null/0)
expect(result.nodes).toHaveLength(1);
expect(result.nodes[0].node).toBe(nodeName);
expect(result.nodes[0].cpu).toBeNull(); // Should be null due to missing data.data
expect(result.nodes[0].cpu).toBeNull();
expect(result.nodes[0].uptime).toBe(0);
// Other fetches should have succeeded with empty data
expect(result.nodes[0].storage).toEqual([]);
expect(result.vms).toHaveLength(0);
expect(result.containers).toHaveLength(0);
@@ -244,55 +253,51 @@ describe('Data Fetcher', () => {
});
test('should handle API error when fetching /nodes for an endpoint', async () => {
// Arrange: Need to define specific mock clients for this test
const mockPveClientInstance1 = { get: jest.fn() };
const mockPveClientInstance2 = { get: jest.fn() };
// Override the default mock return value for initializeApiClients if needed,
// OR just create the clients manually and pass them directly like before.
// Passing manually is simpler:
const mockClients = {
primary: { client: mockPveClientInstance1, config: { id: 'primary', name: 'pve1' } },
secondary: { client: mockPveClientInstance2, config: { id: 'secondary', name: 'pve2' } },
const mockPveClientInstance2 = { get: jest.fn() }; // Separate instance for pve2
const mockClients = { // Custom clients for this test
pve1: { client: mockPveClientInstance1, config: { id: 'pve1', name: 'PVE1 Endpoint' } },
pve2: { client: mockPveClientInstance2, config: { id: 'pve2', name: 'PVE2 Endpoint' } },
};
const nodesError = new Error('Network Error on PVE1');
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); // SETUP SPY HERE
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
// Configure mocks
mockPveClientInstance1.get.mockRejectedValueOnce(nodesError);
// Configure mockPveClientInstance1 (fails on main /nodes call)
mockPveClientInstance1.get
.mockResolvedValueOnce({ data: { data: [{ type: 'cluster', nodes: 1, name: 'pve1-cluster' }] } }) // 1. /cluster/status
.mockResolvedValueOnce({ data: { data: [{ node: 'pve1-node-temp' }] } }) // 2. /nodes (standalone)
.mockRejectedValueOnce(nodesError); // 3. /nodes (main list) -> FAILS
// Configure mockPveClientInstance2 (succeeds fully, no VMs/CTs)
mockPveClientInstance2.get
.mockResolvedValueOnce({ data: { data: [{ node: 'node-pve2', status: 'online' }] } })
.mockResolvedValueOnce({ data: { data: { cpu: 0.1, uptime: 10 } } })
.mockResolvedValueOnce({ data: { data: [] } })
.mockResolvedValueOnce({ data: { data: [] } })
.mockResolvedValueOnce({ data: { data: [] } });
.mockResolvedValueOnce({ data: { data: [{ type: 'cluster', nodes: 1, name: 'pve2-cluster' }] } }) // 1. /cluster/status
.mockResolvedValueOnce({ data: { data: [{ node: 'node-pve2' }] } }) // 2. /nodes (standalone)
.mockResolvedValueOnce({ data: { data: [{ node: 'node-pve2', status: 'online' }] } }) // 3. /nodes (main list)
.mockResolvedValueOnce({ data: { data: { cpu: 0.1, uptime: 10 } } }) // 4. /nodes/node-pve2/status
.mockResolvedValueOnce({ data: { data: [] } }) // 5. /nodes/node-pve2/storage
.mockResolvedValueOnce({ data: { data: [] } }) // 6. /nodes/node-pve2/qemu
.mockResolvedValueOnce({ data: { data: [] } }); // 7. /nodes/node-pve2/lxc <<< SHOULD BE EMPTY
// Act
const result = await fetchDiscoveryData(mockClients, {}); // Pass the specific mock clients
const result = await fetchDiscoveryData(mockClients, {}); // Pass custom clients
// Assert
expect(mockPveClientInstance1.get).toHaveBeenCalledTimes(1);
expect(mockPveClientInstance1.get).toHaveBeenCalledWith('/nodes');
expect(mockPveClientInstance2.get).toHaveBeenCalledTimes(5); // nodes, status, storage, qemu, lxc
// Check that the error for the failed endpoint was logged
// Assertions for pve1 (failed endpoint)
expect(mockPveClientInstance1.get).toHaveBeenCalledTimes(3);
expect(consoleErrorSpy).toHaveBeenCalledWith(
// The error is now caught and logged within fetchDataForPveEndpoint
expect.stringContaining(`[DataFetcher - pve1] Error fetching PVE discovery data: ${nodesError.message}`)
expect.stringContaining(`[DataFetcher - PVE1 Endpoint] Error fetching PVE discovery data: ${nodesError.message}`)
);
// The second log message about endpoint failure is no longer generated in this path after refactor
// expect(consoleErrorSpy).toHaveBeenCalledWith(
// expect.stringContaining(`[DataFetcher] PVE discovery failed for endpoint: pve1. Reason: ${nodesError.message}`)
// );
// Should still return data from the successful endpoint (as fetchDataForPveEndpoint returns empty on error)
// Assert mockPveClientInstance2 (successful endpoint)
expect(mockPveClientInstance2.get).toHaveBeenCalledTimes(7);
// Should still return data from the successful endpoint (pve2)
expect(result.nodes).toHaveLength(1);
expect(result.nodes[0].node).toBe('node-pve2');
expect(result.nodes[0].endpointId).toBe('secondary'); // Corrected expectation: Should be the endpointId from the mock client setup
expect(result.nodes[0].endpointId).toBe('pve2');
expect(result.vms).toHaveLength(0);
expect(result.containers).toHaveLength(0);
expect(result.pbs).toHaveLength(0);
consoleErrorSpy.mockRestore(); // RESTORE SPY HERE
consoleErrorSpy.mockRestore();
});
test('should handle API error when fetching guests for a specific node', async () => {
@@ -374,34 +379,34 @@ describe('Data Fetcher', () => {
test('should handle missing or invalid data.data for a node resource', async () => {
// Arrange: Use default mock client and a specific node name
const nodeName = 'node-missing-data-data';
const nodeName = 'node-missing-data-data'; // Can be the same or different, impact is on the mock
const endpointId = 'primary';
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
// Mock the /nodes call to return the node
mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: [{ node: nodeName, status: 'online' }] } }); // /nodes
// Mock the /status call to return data with null data.data (covers lines 108-113)
mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: null } }); // status
// Mock other calls to succeed with empty data to allow the test to proceed
mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: [] } }); // storage
mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: [] } }); // qemu
mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: [] } }); // lxc
mockPveClientInstance.get
// Endpoint level calls
.mockResolvedValueOnce({ data: { data: [{ type: 'cluster', nodes: 1, name: 'test-cluster' }] } }) // 1. /cluster/status
.mockResolvedValueOnce({ data: { data: [{ node: nodeName }] } }) // 2. /nodes (for standaloneNodeName)
.mockResolvedValueOnce({ data: { data: [{ node: nodeName, status: 'online' }] } }) // 3. /nodes (main node list for endpoint)
// Node level calls for nodeName
.mockResolvedValueOnce({ data: { data: null } }) // 4. /nodes/${nodeName}/status (INVALID data.data)
.mockResolvedValueOnce({ data: { data: [] } }) // 5. /nodes/${nodeName}/storage
.mockResolvedValueOnce({ data: { data: [] } }) // 6. /nodes/${nodeName}/qemu
.mockResolvedValueOnce({ data: { data: [] } }); // 7. /nodes/${nodeName}/lxc
// Act
const result = await fetchDiscoveryData(mockPveApiClient, mockPbsApiClient);
// Assert
// Verify the warning was logged by fetchNodeResource (covers line 109)
// Verify the warning was logged by fetchNodeResource
expect(consoleWarnSpy).toHaveBeenCalledWith(
`[DataFetcher - primary-${nodeName}] Node status data missing or invalid format.`
`[DataFetcher - ${endpointId}-${nodeName}] Node status data missing or invalid format.`
);
// Verify that the node was still processed but status data is default (null/0)
expect(result.nodes).toHaveLength(1);
expect(result.nodes[0].node).toBe(nodeName);
expect(result.nodes[0].cpu).toBeNull(); // Should be null due to missing data.data
expect(result.nodes[0].cpu).toBeNull();
expect(result.nodes[0].uptime).toBe(0);
// Other fetches should have succeeded with empty data
expect(result.nodes[0].storage).toEqual([]);
expect(result.vms).toHaveLength(0);
expect(result.containers).toHaveLength(0);
@@ -415,11 +420,15 @@ describe('Data Fetcher', () => {
const nodeName = 'pve-node';
const vmId = 200;
mockPveClientInstance.get
.mockResolvedValueOnce({ data: { data: [{ node: nodeName, status: 'online' }] } }) // /nodes
.mockResolvedValueOnce({ data: { data: { uptime: 1 } } }) // status
.mockResolvedValueOnce({ data: { data: [] } }) // storage
.mockResolvedValueOnce({ data: { data: [{ vmid: vmId, name: 'pve-vm' }] } }) // qemu
.mockResolvedValueOnce({ data: { data: [] } }); // lxc
// PVE Endpoint calls
.mockResolvedValueOnce({ data: { data: [{ type: 'cluster', nodes: 1, name: 'test-cluster' }] } }) // 1. /cluster/status
.mockResolvedValueOnce({ data: { data: [{ node: nodeName }] } }) // 2. /nodes (standalone)
.mockResolvedValueOnce({ data: { data: [{ node: nodeName, status: 'online' }] } }) // 3. /nodes (main list)
// PVE Node calls for nodeName
.mockResolvedValueOnce({ data: { data: { uptime: 1 } } }) // 4. /nodes/${nodeName}/status
.mockResolvedValueOnce({ data: { data: [] } }) // 5. /nodes/${nodeName}/storage
.mockResolvedValueOnce({ data: { data: [{ vmid: vmId, name: 'pve-vm' }] } }) // 6. /nodes/${nodeName}/qemu
.mockResolvedValueOnce({ data: { data: [] } }); // 7. /nodes/${nodeName}/lxc
// Arrange PBS (Mock the function to be injected)
const mockPbsFunction = jest.fn();
@@ -489,11 +498,15 @@ describe('Data Fetcher', () => {
const nodeName = 'pve-node';
const vmId = 200;
mockPveClientInstance.get
.mockResolvedValueOnce({ data: { data: [{ node: nodeName, status: 'online' }] } }) // /nodes
.mockResolvedValueOnce({ data: { data: { uptime: 1 } } }) // status
.mockResolvedValueOnce({ data: { data: [] } }) // storage
.mockResolvedValueOnce({ data: { data: [{ vmid: vmId, name: 'pve-vm' }] } }) // qemu
.mockResolvedValueOnce({ data: { data: [] } }); // lxc
// PVE Endpoint calls
.mockResolvedValueOnce({ data: { data: [{ type: 'cluster', nodes: 1, name: 'test-cluster' }] } }) // 1. /cluster/status
.mockResolvedValueOnce({ data: { data: [{ node: nodeName }] } }) // 2. /nodes (standalone)
.mockResolvedValueOnce({ data: { data: [{ node: nodeName, status: 'online' }] } }) // 3. /nodes (main list)
// PVE Node calls for nodeName
.mockResolvedValueOnce({ data: { data: { uptime: 1 } } }) // 4. /nodes/${nodeName}/status
.mockResolvedValueOnce({ data: { data: [] } }) // 5. /nodes/${nodeName}/storage
.mockResolvedValueOnce({ data: { data: [{ vmid: vmId, name: 'pve-vm' }] } }) // 6. /nodes/${nodeName}/qemu
.mockResolvedValueOnce({ data: { data: [] } }); // 7. /nodes/${nodeName}/lxc
// Arrange PBS: Pass an empty object for PBS clients, mock injected function
const emptyPbsClients = {};
@@ -513,144 +526,187 @@ describe('Data Fetcher', () => {
});
test('should handle error fetching Containers (lxc)', async () => {
// Arrange: Use default mock client
const nodeName = 'node-bad-lxc';
// Arrange: Uses the default mock clients from beforeEach
const nodeNameGood = 'node-good';
const nodeNameBad = 'node-bad-guests'; // This node will have the LXC fetch error
const endpointId = 'primary'; // Default endpointId from mockPveApiClient setup
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockPveClientInstance.get
.mockResolvedValueOnce({ data: { data: [{ node: nodeName, status: 'online' }] } }) // /nodes
.mockResolvedValueOnce({ data: { data: { cpu: 0.1, uptime: 10 } } }) // status
.mockResolvedValueOnce({ data: { data: [] } }) // storage (empty)
.mockResolvedValueOnce({ data: { data: [{ vmid: 200, name: 'vm-ok', status: 'running' }] } }) // qemu (ok)
// ** Reject lxc call **
.mockRejectedValueOnce(new Error('Simulated LXC Fetch Error')); // lxc
mockPveClientInstance.get.mockImplementation(async (url) => {
// Endpoint level calls - needed for the overall fetchDiscoveryData structure
if (url === '/cluster/status') {
return { data: { data: [{ type: 'cluster', nodes: 2, name: 'test-cluster' }] } }; // Simulate a cluster with 2 nodes
}
if (url === '/nodes') { // This is the main /nodes call for the endpoint
return { data: { data: [
{ node: nodeNameGood, status: 'online', id: `node/${nodeNameGood}` },
{ node: nodeNameBad, status: 'online', id: `node/${nodeNameBad}` }
]}};
}
// Act
// Calls for nodeNameGood (all succeed)
if (url === `/nodes/${nodeNameGood}/status`) return { data: { data: { cpu: 0.1, uptime: 10 } } };
if (url === `/nodes/${nodeNameGood}/storage`) return { data: { data: [] } };
if (url === `/nodes/${nodeNameGood}/qemu`) return { data: { data: [ { vmid: 100, name: 'vm-good', status: 'running' } ] } };
if (url === `/nodes/${nodeNameGood}/lxc`) return { data: { data: [] } };
// Calls for nodeNameBad
if (url === `/nodes/${nodeNameBad}/status`) return { data: { data: { cpu: 0.2, uptime: 20 } } };
if (url === `/nodes/${nodeNameBad}/storage`) return { data: { data: [] } };
if (url === `/nodes/${nodeNameBad}/qemu`) return { data: { data: [] } }; // QEMU succeeds
if (url === `/nodes/${nodeNameBad}/lxc`) { // LXC fetch fails
throw new Error('Simulated LXC Fetch Error');
}
// Fallback for unexpected calls
console.warn(`Unexpected API call in mockImplementation: ${url}`);
throw new Error(`Unexpected API call in mock: ${url}`);
});
// Act: Uses the default mock clients from beforeEach
const result = await fetchDiscoveryData(mockPveApiClient, mockPbsApiClient);
// Assert
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(`[DataFetcher - primary-${nodeName}] Error fetching Containers (lxc): Simulated LXC Fetch Error`)
expect.stringContaining(`[DataFetcher - ${endpointId}-${nodeNameBad}] Error fetching Containers (lxc): Simulated LXC Fetch Error`)
);
// Node and VMs should still be present
expect(result.nodes).toHaveLength(1);
expect(result.nodes[0].node).toBe(nodeName);
expect(result.vms).toHaveLength(1);
expect(result.vms[0].vmid).toBe(200);
// Containers should be empty
expect(result.containers).toHaveLength(0);
// Check that the correct number of nodes is returned
expect(result.nodes).toHaveLength(2);
const goodNodeResult = result.nodes.find(n => n.node === nodeNameGood);
const badNodeResult = result.nodes.find(n => n.node === nodeNameBad);
expect(goodNodeResult).toBeDefined();
expect(badNodeResult).toBeDefined();
// VMs/CTs from the good node should be present
expect(result.vms).toHaveLength(1); // From nodeNameGood
expect(result.vms[0].vmid).toBe(100);
// Containers from nodeNameGood are [], and from nodeNameBad failed, so overall should be []
expect(result.containers).toHaveLength(0);
consoleErrorSpy.mockRestore();
});
test('should handle errors fetching node status and storage', async () => {
// Arrange: Use default mock client
const nodeName = 'node-bad-status-storage';
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const statusError = new Error('Status fetch failed');
const storageError = new Error('Storage fetch failed');
// Arrange
const nodeName = 'node-bad-status-storage'; // This won't be used as errors occur at endpoint/node list level
const endpointId = 'primary';
const endpointConfig = mockPveApiClient[endpointId].config; // Get the config for name
mockPveClientInstance.get
.mockResolvedValueOnce({ data: { data: [{ node: nodeName, status: 'online' }] } }) // /nodes
.mockRejectedValueOnce(statusError) // status fails
.mockRejectedValueOnce(storageError) // storage fails
.mockResolvedValueOnce({ data: { data: [] } }) // qemu (ok)
.mockResolvedValueOnce({ data: { data: [] } }); // lxc (ok)
const statusError = new Error('Status fetch failed');
const storageError = new Error('Storage fetch failed'); // This will be for the /nodes call
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockPveClientInstance.get.mockImplementation(async (url) => {
if (url === '/cluster/status') {
// console.log(`Mock: ${url} throwing statusError`);
throw statusError;
}
// This will be called by the catch block of /cluster/status, and potentially the main /nodes call
if (url === '/nodes') {
// console.log(`Mock: ${url} throwing storageError`);
throw storageError;
}
// console.warn(`Mock: Unexpected call to ${url}`);
throw new Error(`Unexpected API call in mock for failing status/storage: ${url}`);
});
// Act
const result = await fetchDiscoveryData(mockPveApiClient, mockPbsApiClient);
// Assert
// Check the first call to console.error specifically for the /cluster/status failure
const firstCallArgs = consoleErrorSpy.mock.calls[0];
const expectedLogMessagePart = `[DataFetcher - primary] Error fetching /cluster/status: ${statusError.message}`;
expect(firstCallArgs[0]).toContain(expectedLogMessagePart);
expect(firstCallArgs[1]).toBeInstanceOf(Error);
expect(firstCallArgs[1].message).toBe(statusError.message);
// Check the second call for the /nodes failure after /cluster/status
expect(consoleErrorSpy).toHaveBeenCalledWith(
// Adjust expected message (capital 'N' in 'Node')
expect.stringContaining(`[DataFetcher - primary-${nodeName}] Error fetching Node status: ${statusError.message}`)
);
expect.stringContaining(`[DataFetcher - ${endpointConfig.name}] Also failed to fetch /nodes after /cluster/status error: ${storageError.message}`)
);
// Third error log from the main catch block in fetchDataForPveEndpoint
expect(consoleErrorSpy).toHaveBeenCalledWith(
// Adjust expected message (capital 'N' in 'Node')
expect.stringContaining(`[DataFetcher - primary-${nodeName}] Error fetching Node storage: ${storageError.message}`)
);
// Ensure node was still processed and added (just without status/storage)
expect(result.nodes).toHaveLength(1);
expect(result.nodes[0].node).toBe(nodeName);
// Check status fields are null (or default) as the status fetch failed
expect(result.nodes[0]).toMatchObject({
cpu: null,
mem: null,
disk: null,
maxdisk: null, // Check maxdisk too
uptime: 0,
loadavg: null,
expect.stringContaining(`[DataFetcher - ${endpointConfig.name}] Error fetching PVE discovery data: ${storageError.message}`)
);
expect(consoleErrorSpy).toHaveBeenCalledTimes(3);
// Data should be empty for this endpoint due to critical failures
expect(result.nodes).toEqual([]);
expect(result.vms).toEqual([]);
expect(result.containers).toEqual([]);
consoleErrorSpy.mockRestore();
});
expect(result.nodes[0].storage).toEqual([]); // Storage is empty
expect(result.vms).toHaveLength(0);
expect(result.containers).toHaveLength(0);
consoleErrorSpy.mockRestore();
});
test('should handle invalid format for node status response (invalid data.data)', async () => {
// Arrange: Use default mock client
const nodeName = 'node-bad-status-data';
const nodeName = 'node-bad-status-data'; // A unique name for this test case
const endpointId = 'primary';
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
mockPveClientInstance.get
.mockResolvedValueOnce({ data: { data: [{ node: nodeName, status: 'online' }] } }) // /nodes
.mockResolvedValueOnce({ data: { data: null } }) // status (data.data is null - should trigger warn)
.mockResolvedValueOnce({ data: { data: [] } }) // storage (ok)
.mockResolvedValueOnce({ data: { data: [] } }) // qemu (ok)
.mockResolvedValueOnce({ data: { data: [] } }); // lxc (ok)
// Endpoint level calls
.mockResolvedValueOnce({ data: { data: [{ type: 'cluster', nodes: 1, name: 'test-cluster' }] } }) // 1. /cluster/status
.mockResolvedValueOnce({ data: { data: [{ node: nodeName }] } }) // 2. /nodes (for standaloneNodeName)
.mockResolvedValueOnce({ data: { data: [{ node: nodeName, status: 'online' }] } }) // 3. /nodes (main node list for endpoint)
// Node level calls for nodeName
.mockResolvedValueOnce({ data: { data: null } }) // <--- Invalid: data.data is null for /status
.mockResolvedValueOnce({ data: { data: [] } }) // storage
.mockResolvedValueOnce({ data: { data: [] } }) // qemu
.mockResolvedValueOnce({ data: { data: [] } }); // lxc
// Act
const result = await fetchDiscoveryData(mockPveApiClient, mockPbsApiClient);
// Assert
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining(`[DataFetcher - primary-${nodeName}] Node status data missing or invalid format.`)
expect.stringContaining(`[DataFetcher - ${endpointId}-${nodeName}] Node status data missing or invalid format.`)
);
// Node should still exist, but status fields should be default
// Node should still exist, but status fields should be default/null
expect(result.nodes).toHaveLength(1);
expect(result.nodes[0].node).toBe(nodeName);
expect(result.nodes[0].cpu).toBeNull();
expect(result.nodes[0].mem).toBeNull(); // or existing value if not overwritten by status
expect(result.nodes[0].uptime).toBe(0);
expect(result.nodes[0].status).toBe('online'); // Status from /nodes remains
consoleWarnSpy.mockRestore();
});
test('should handle invalid format for node status response', async () => {
// Arrange: Use default mock client
const nodeName = 'node-bad-status-format';
const nodeName = 'node-bad-status-format'; // Test a node where its /status call fails
const endpointId = 'primary';
const endpointConfig = mockPveApiClient[endpointId].config;
const statusFetchError = new Error('Node Status Network Error');
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockPveClientInstance.get
.mockResolvedValueOnce({ data: { data: [{ node: nodeName, status: 'online' }] } }) // /nodes
.mockRejectedValueOnce(new Error('Status fetch failed')) // status fails
.mockResolvedValueOnce({ data: { data: [] } }) // qemu (ok)
.mockResolvedValueOnce({ data: { data: [] } }); // lxc (ok)
.mockResolvedValueOnce({ data: { data: [{ type: 'cluster', nodes: 1, name: 'test-cluster' }] } }) // 1. /cluster/status
.mockResolvedValueOnce({ data: { data: [{ node: nodeName }] } }) // 2. /nodes (for standaloneNodeName)
.mockResolvedValueOnce({ data: { data: [{ node: nodeName, status: 'online' }] } }) // 3. /nodes (main node list for endpoint)
.mockRejectedValueOnce(statusFetchError) // 4. /nodes/${nodeName}/status << THIS FAILS
.mockResolvedValueOnce({ data: { data: [] } }) // 5. /nodes/${nodeName}/storage (subsequent calls should still be mocked)
.mockResolvedValueOnce({ data: { data: [] } }) // 6. /nodes/${nodeName}/qemu
.mockResolvedValueOnce({ data: { data: [] } }); // 7. /nodes/${nodeName}/lxc
// Act
const result = await fetchDiscoveryData(mockPveApiClient, mockPbsApiClient);
// Assert
expect(consoleErrorSpy).toHaveBeenCalledWith(
// Adjust expected message (capital 'N' in 'Node')
expect.stringContaining(`[DataFetcher - primary-${nodeName}] Error fetching Node status: Status fetch failed`)
);
// Ensure node was still processed and added (just without status)
// Assert error logging
// Only one log from fetchNodeResource, as it catches the error and returns null,
// so the promise in fetchDataForPveEndpoint for this node is fulfilled.
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(`[DataFetcher - ${endpointId}-${nodeName}] Error fetching Node status: ${statusFetchError.message}`)
);
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
// Assert node data (node should exist, but with default/error state for status)
expect(result.nodes).toHaveLength(1);
expect(result.nodes[0].node).toBe(nodeName);
// Check status fields are null (or default) as the status fetch failed
expect(result.nodes[0]).toMatchObject({
cpu: null,
mem: null,
disk: null,
maxdisk: null, // Check maxdisk too
uptime: 0,
loadavg: null,
});
expect(result.nodes[0].storage).toEqual([]); // Storage is empty
expect(result.vms).toHaveLength(0);
expect(result.containers).toHaveLength(0);
expect(result.nodes[0].cpu).toBeNull();
expect(result.nodes[0].mem).toBeNull();
expect(result.nodes[0].uptime).toBe(0);
// Other parts like storage should be processed if their mocks are fine (empty array here)
expect(result.nodes[0].storage).toEqual([]);
consoleErrorSpy.mockRestore();
});
+10
View File
@@ -5,3 +5,13 @@
.overflow-y-hidden {
overflow-y: hidden;
}
.subtle-stripes-light {
background-image: linear-gradient(45deg, rgba(0, 0, 0, 0.05) 25%, transparent 25%, transparent 50%, rgba(0, 0, 0, 0.05) 50%, rgba(0, 0, 0, 0.05) 75%, transparent 75%, transparent);
background-size: 10px 10px;
}
.subtle-stripes-dark {
background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.04) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.04) 50%, rgba(255, 255, 255, 0.04) 75%, transparent 75%, transparent);
background-size: 10px 10px; /* Explicitly set for dark mode too */
}
+20 -14
View File
@@ -278,30 +278,36 @@ PulseApp.ui.common = (() => {
}
function resetDashboardView() {
// Reset search
if (searchInput) searchInput.value = '';
PulseApp.state.setSortState('main', 'id', 'asc');
const groupGroupedRadio = document.getElementById('group-grouped');
if(groupGroupedRadio) groupGroupedRadio.checked = true;
// Reset filters to defaults
PulseApp.state.set('groupByNode', true);
const filterAllRadio = document.getElementById('filter-all');
if(filterAllRadio) filterAllRadio.checked = true;
document.getElementById('group-grouped').checked = true;
PulseApp.state.set('filterGuestType', 'all');
const statusAllRadio = document.getElementById('filter-status-all');
if(statusAllRadio) statusAllRadio.checked = true;
document.getElementById('filter-all').checked = true;
PulseApp.state.set('filterStatus', 'all');
document.getElementById('filter-status-all').checked = true;
PulseApp.ui.thresholds.resetThresholds();
// Reset thresholds
PulseApp.ui.thresholds.resetThresholds(); // This will also trigger a save
// Update table and save states
PulseApp.ui.dashboard.updateDashboardTable();
if (searchInput) searchInput.blur();
PulseApp.ui.thresholds.updateLogControlsVisibility();
PulseApp.state.saveFilterState(); // Save reset state
PulseApp.state.saveFilterState(); // Thresholds are saved by its own reset
// Sort state is not reset by this action intentionally
}
function generateNodeGroupHeaderCellHTML(text, colspan, cellTag = 'td') {
const cellClasses = 'py-0.5 px-2 bg-gray-200 dark:bg-gray-700 subtle-stripes-light dark:subtle-stripes-dark text-left font-medium text-sm text-gray-700 dark:text-gray-300';
return `<${cellTag} colspan="${colspan}" class="${cellClasses}">${text}</${cellTag}>`;
}
return {
init,
updateSortUI
updateSortUI,
setupTableSorting,
resetDashboardView,
generateNodeGroupHeaderCellHTML
};
})();
+1 -4
View File
@@ -274,10 +274,7 @@ PulseApp.ui.dashboard = (() => {
visibleNodes.add(nodeName.toLowerCase());
const nodeHeaderRow = document.createElement('tr');
nodeHeaderRow.className = 'node-header bg-gray-100 dark:bg-gray-700/80 font-semibold text-gray-700 dark:text-gray-300 text-xs';
nodeHeaderRow.innerHTML = `<td colspan="11" class="px-2 py-1">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block mr-1 align-middle"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect><rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line x1="6" y1="18" x2="6.01" y2="18"></line></svg>
${nodeName}
</td>`;
nodeHeaderRow.innerHTML = PulseApp.ui.common.generateNodeGroupHeaderCellHTML(nodeName, 11, 'td');
tableBody.appendChild(nodeHeaderRow);
nodeGroups[nodeName].forEach(guest => {
+43 -10
View File
@@ -79,20 +79,53 @@ PulseApp.ui.nodes = (() => {
console.error('Critical element #nodes-table-body not found for nodes table update!');
return;
}
// tbody.innerHTML = ''; // Handled by renderTableBody
tbody.innerHTML = ''; // Clear previous content
if (!nodes || nodes.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="p-4 text-center text-gray-500 dark:text-gray-400">No nodes found or data unavailable</td></tr>';
return;
}
// Group nodes by clusterIdentifier
const clusters = nodes.reduce((acc, node) => {
const key = node.clusterIdentifier || 'Unknown Cluster'; // Fallback for safety
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(node);
return acc;
}, {});
const sortStateNodes = PulseApp.state.getSortState('nodes');
const dataToDisplay = PulseApp.utils.sortData(nodes, sortStateNodes.column, sortStateNodes.direction, 'nodes');
// if (dataToDisplay.length === 0) { // Handled by renderTableBody
// tbody.innerHTML = '<tr><td colspan="7" class="p-4 text-center text-gray-500 dark:text-gray-400">No nodes found or data unavailable</td></tr>';
// return;
// }
// Iterate over each cluster group
for (const clusterIdentifier in clusters) {
if (clusters.hasOwnProperty(clusterIdentifier)) {
const nodesInCluster = clusters[clusterIdentifier];
// All nodes in this group should have the same endpointType, so pick from the first.
// Default to 'standalone' if nodesInCluster is empty or endpointType is missing for some reason.
const endpointType = (nodesInCluster && nodesInCluster.length > 0 && nodesInCluster[0].endpointType)
? nodesInCluster[0].endpointType
: 'standalone';
const iconSvg = endpointType === 'cluster'
? PulseApp.ui.common.NODE_GROUP_CLUSTER_ICON_SVG
: PulseApp.ui.common.NODE_GROUP_STANDALONE_ICON_SVG;
// Use the utility function
PulseApp.utils.renderTableBody(tbody, dataToDisplay, createNodeRow, "No nodes found or data unavailable", 7);
// REMOVED: dataToDisplay.forEach(node => { ... tbody.appendChild(row); });
const clusterHeaderRow = document.createElement('tr');
// Applying base background, then overlaying with stripe pattern classes
clusterHeaderRow.innerHTML = PulseApp.ui.common.generateNodeGroupHeaderCellHTML(clusterIdentifier, 7, 'th');
tbody.appendChild(clusterHeaderRow);
// Sort nodes within this cluster group
const sortedNodesInCluster = PulseApp.utils.sortData(nodesInCluster, sortStateNodes.column, sortStateNodes.direction, 'nodes');
sortedNodesInCluster.forEach(node => {
const nodeRow = createNodeRow(node);
tbody.appendChild(nodeRow);
});
}
}
}
return {
+1 -5
View File
@@ -123,11 +123,7 @@ PulseApp.ui.storage = (() => {
const nodeHeaderRow = document.createElement('tr');
nodeHeaderRow.className = 'bg-gray-100 dark:bg-gray-700/80 font-semibold text-gray-700 dark:text-gray-300 text-xs node-storage-header';
nodeHeaderRow.innerHTML = `
<td colspan="7" class="px-2 py-1">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block mr-1 align-middle"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect><rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line x1="6" y1="18" x2="6.01" y2="18"></line></svg>
Node: ${nodeName}
</td>`;
nodeHeaderRow.innerHTML = PulseApp.ui.common.generateNodeGroupHeaderCellHTML(`Node: ${nodeName}`, 7, 'td');
tbody.appendChild(nodeHeaderRow);
if (nodeStorageData.length === 0) {