mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 19:23:31 +00:00
Merge release/v3.6.1 into main
This commit is contained in:
+57
-2
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user