From bc86e8f019841fd614769d1a2025d217dd0144f5 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Date: Mon, 9 Mar 2026 16:39:44 -0400 Subject: [PATCH] CONSOLE-4946: Add Configuration tab to Node view --- .../console-app/locales/en/console-app.json | 25 +- .../src/components/nodes/NodeDetailsPage.tsx | 7 + .../src/components/nodes/NodeSubNavPage.tsx | 109 +++++ .../src/components/nodes/NodesPage.tsx | 2 +- .../nodes/configuration/NodeConfiguration.tsx | 30 ++ .../nodes/configuration/NodeMachine.tsx | 126 ++++++ .../__tests__/NodeConfiguration.spec.tsx | 402 ++++++++++++++++++ .../__tests__/NodeMachine.spec.tsx | 180 ++++++++ .../configuration/node-storage/LocalDisks.tsx | 98 +++++ .../node-storage/NodeStorage.tsx | 28 ++ .../node-storage/PersistentVolumes.tsx | 350 +++++++++++++++ .../__tests__/LocalDisks.spec.tsx | 164 +++++++ .../__tests__/NodeStorage.spec.tsx | 42 ++ .../__tests__/PersistentVolumes.spec.tsx | 328 ++++++++++++++ .../BareMetalInventoryItems.tsx | 2 +- .../nodes/node-dashboard/InventoryCard.tsx | 2 +- .../BareMetalInventoryItems.spec.tsx | 8 +- .../nodes/{ => utils}/NodeBareMetalUtils.ts | 15 +- .../nodes/{ => utils}/NodeVmUtils.ts | 29 +- .../__tests__/NodeBareMetalUtils.spec.ts | 29 +- .../{ => utils}/__tests__/NodeVmUtils.spec.ts | 10 +- .../nodes/utils/useAccessibleResources.ts | 87 ++++ .../docs/console-extensions.md | 61 ++- .../src/extensions/index.ts | 1 + .../src/extensions/node-subnav-tabs.ts | 34 ++ .../src/schema/console-extensions.ts | 2 + .../public/components/machine-config-pool.tsx | 6 +- frontend/public/components/machine.tsx | 13 +- 28 files changed, 2132 insertions(+), 58 deletions(-) create mode 100644 frontend/packages/console-app/src/components/nodes/NodeSubNavPage.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/configuration/NodeConfiguration.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/configuration/NodeMachine.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/configuration/__tests__/NodeConfiguration.spec.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/configuration/__tests__/NodeMachine.spec.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/configuration/node-storage/LocalDisks.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/configuration/node-storage/NodeStorage.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/configuration/node-storage/PersistentVolumes.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/configuration/node-storage/__tests__/LocalDisks.spec.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/configuration/node-storage/__tests__/NodeStorage.spec.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/configuration/node-storage/__tests__/PersistentVolumes.spec.tsx rename frontend/packages/console-app/src/components/nodes/{ => utils}/NodeBareMetalUtils.ts (91%) rename frontend/packages/console-app/src/components/nodes/{ => utils}/NodeVmUtils.ts (82%) rename frontend/packages/console-app/src/components/nodes/{ => utils}/__tests__/NodeBareMetalUtils.spec.ts (89%) rename frontend/packages/console-app/src/components/nodes/{ => utils}/__tests__/NodeVmUtils.spec.ts (96%) create mode 100644 frontend/packages/console-app/src/components/nodes/utils/useAccessibleResources.ts create mode 100644 frontend/packages/console-dynamic-plugin-sdk/src/extensions/node-subnav-tabs.ts diff --git a/frontend/packages/console-app/locales/en/console-app.json b/frontend/packages/console-app/locales/en/console-app.json index e612638cd5f..3935a932a1a 100644 --- a/frontend/packages/console-app/locales/en/console-app.json +++ b/frontend/packages/console-app/locales/en/console-app.json @@ -351,6 +351,30 @@ "Unpin": "Unpin", "Remove from navigation?": "Remove from navigation?", "Remove": "Remove", + "Rotational": "Rotational", + "SSD": "SSD", + "Local disks": "Local disks", + "Unable to load local disks": "Unable to load local disks", + "Model": "Model", + "Serial number": "Serial number", + "Vendor": "Vendor", + "HCTL": "HCTL", + "No local disks found": "No local disks found", + "Node storage": "Node storage", + "No claim": "No claim", + "None": "None", + "Mounted persistent volumes": "Mounted persistent volumes", + "Unable to load persistent volumes": "Unable to load persistent volumes", + "PVC": "PVC", + "Pod": "Pod", + "Capacity": "Capacity", + "No persistent volumes found": "No persistent volumes found", + "Machine": "Machine", + "Error loading machine": "Error loading machine", + "Machine not found": "Machine not found", + "Error loading machine config pool": "Error loading machine config pool", + "Configuration": "Configuration", + "Machine config pool not found": "Machine config pool not found", "This action cannot be undone. Deleting a node will instruct Kubernetes that the node is down or unrecoverable and delete all pods scheduled to that node. If the node is still running but unresponsive and the node is deleted, stateful workloads and persistent volumes may suffer corruption or data loss. Only delete a node that you have confirmed is completely stopped and cannot be restored.": "This action cannot be undone. Deleting a node will instruct Kubernetes that the node is down or unrecoverable and delete all pods scheduled to that node. If the node is still running but unresponsive and the node is deleted, stateful workloads and persistent volumes may suffer corruption or data loss. Only delete a node that you have confirmed is completely stopped and cannot be restored.", "Mark as schedulable": "Mark as schedulable", "Mark as unschedulable": "Mark as unschedulable", @@ -403,7 +427,6 @@ "Annotations": "Annotations", "Annotation_one": "Annotation", "Annotation_other": "Annotations", - "Machine": "Machine", "Provider ID": "Provider ID", "Unschedulable": "Unschedulable", "Created": "Created", diff --git a/frontend/packages/console-app/src/components/nodes/NodeDetailsPage.tsx b/frontend/packages/console-app/src/components/nodes/NodeDetailsPage.tsx index 0ef0cfbfd74..f471f221600 100644 --- a/frontend/packages/console-app/src/components/nodes/NodeDetailsPage.tsx +++ b/frontend/packages/console-app/src/components/nodes/NodeDetailsPage.tsx @@ -14,6 +14,7 @@ import { } from '@console/shared/src/components/actions'; import { isWindowsNode } from '@console/shared/src/selectors/node'; import { nodeStatus } from '../../status/node'; +import { NodeConfiguration } from './configuration/NodeConfiguration'; import NodeDashboard from './node-dashboard/NodeDashboard'; import NodeDetails from './NodeDetails'; import NodeLogs from './NodeLogs'; @@ -43,6 +44,12 @@ export const NodeDetailsPage: FC> = (props) = component: NodeDetails, }, navFactory.editYaml(), + { + href: 'configuration', + // t('console-app~Configuration') + nameKey: 'console-app~Configuration', + component: NodeConfiguration, + }, navFactory.pods(NodePodsPage), navFactory.logs(NodeLogs), navFactory.events(ResourceEventStream), diff --git a/frontend/packages/console-app/src/components/nodes/NodeSubNavPage.tsx b/frontend/packages/console-app/src/components/nodes/NodeSubNavPage.tsx new file mode 100644 index 00000000000..050bdd549d9 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/NodeSubNavPage.tsx @@ -0,0 +1,109 @@ +import type { ComponentType, FC } from 'react'; +import { useMemo } from 'react'; +import { useResolvedExtensions } from '@openshift/dynamic-plugin-sdk'; +import { Bullseye, Flex, FlexItem, Spinner, Tab, Tabs, TabTitleText } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import type { K8sResourceCommon, NodeKind, NodeSubNavTab } from '@console/dynamic-plugin-sdk/src'; +import { isNodeSubNavTab } from '@console/dynamic-plugin-sdk/src'; +import type { PageComponentProps } from '@console/internal/components/utils'; +import { useQueryParamsMutator } from '@console/internal/components/utils'; +import { useTranslatedExtensions } from '@console/plugin-sdk/src/utils/useTranslatedExtensions'; +import { useQueryParams } from '@console/shared/src'; + +export type SubPageType = { + component: ComponentType>; + tabId: string; + name?: string; + nameKey?: string; + priority: number; +}; + +type NodeSubNavPageProps = { + obj: NodeKind; + pageId: string; + standardPages: SubPageType[]; +}; + +export const NodeSubNavPage: FC = ({ obj, pageId, standardPages }) => { + const { t } = useTranslation(); + const queryParams = useQueryParams(); + const { setAllQueryArguments } = useQueryParamsMutator(); + const activeTabKey = queryParams.get('activeTab'); + + const setActiveTabKey = (key: string) => { + setAllQueryArguments({ activeTab: key }); + }; + + const [subTabExtensions, extensionsResolved] = useResolvedExtensions( + isNodeSubNavTab, + ); + const nodeSubTabExtensions = useTranslatedExtensions(subTabExtensions ?? []); + + const pages: SubPageType[] = useMemo(() => { + if (!extensionsResolved) { + return standardPages; + } + return [ + ...standardPages, + ...nodeSubTabExtensions + .filter((ext) => ext.properties.parentTab === pageId) + .map((ext) => ({ + ...ext.properties.page, + component: ext.properties.component, + })), + ].sort((a, b) => b.priority - a.priority); + }, [pageId, standardPages, nodeSubTabExtensions, extensionsResolved]); + + const activePage = pages.find((page) => page.tabId === activeTabKey) ?? (pages[0] || null); + const Component = activePage?.component; + + return ( + + {!extensionsResolved ? ( + + + + + + ) : ( + <> + + { + setActiveTabKey(String(tabId)); + }} + > + {pages.map(({ nameKey, name, tabId }) => { + return ( + {nameKey ? t(nameKey) : name}} + aria-controls={undefined} // there is no corresponding tab content to control, so this ID is invalid + /> + ); + })} + + + {Component ? ( + + + + ) : null} + + )} + + ); +}; diff --git a/frontend/packages/console-app/src/components/nodes/NodesPage.tsx b/frontend/packages/console-app/src/components/nodes/NodesPage.tsx index 8474668df8c..f337cbf195c 100644 --- a/frontend/packages/console-app/src/components/nodes/NodesPage.tsx +++ b/frontend/packages/console-app/src/components/nodes/NodesPage.tsx @@ -21,7 +21,7 @@ import { filterVirtualMachineInstancesByNode, useIsKubevirtPluginActive, useWatchVirtualMachineInstances, -} from '@console/app/src/components/nodes/NodeVmUtils'; +} from '@console/app/src/components/nodes/utils/NodeVmUtils'; import type { K8sModel } from '@console/dynamic-plugin-sdk/src/api/dynamic-core-api'; import { getGroupVersionKindForResource, diff --git a/frontend/packages/console-app/src/components/nodes/configuration/NodeConfiguration.tsx b/frontend/packages/console-app/src/components/nodes/configuration/NodeConfiguration.tsx new file mode 100644 index 00000000000..f0654af685c --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/NodeConfiguration.tsx @@ -0,0 +1,30 @@ +import type { FC } from 'react'; +import type { NodeKind } from '@console/dynamic-plugin-sdk/src'; +import { NodeSubNavPage } from '../NodeSubNavPage'; +import NodeStorage from './node-storage/NodeStorage'; +import NodeMachine from './NodeMachine'; + +type NodeConfigurationProps = { + obj: NodeKind; +}; + +const standardPages = [ + { + tabId: 'storage', + // t('console-app~Storage') + nameKey: 'console-app~Storage', + component: NodeStorage, + priority: 70, + }, + { + tabId: 'machine', + // t('console-app~Machine') + nameKey: 'console-app~Machine', + component: NodeMachine, + priority: 50, + }, +]; + +export const NodeConfiguration: FC = ({ obj }) => ( + +); diff --git a/frontend/packages/console-app/src/components/nodes/configuration/NodeMachine.tsx b/frontend/packages/console-app/src/components/nodes/configuration/NodeMachine.tsx new file mode 100644 index 00000000000..35d2d7dfdc0 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/NodeMachine.tsx @@ -0,0 +1,126 @@ +import type { ComponentType, FC } from 'react'; +import { useMemo } from 'react'; +import { Grid, GridItem } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks'; +import { MachineDetails } from '@console/internal/components/machine'; +import { + MachineConfigPoolCharacteristics, + MachineConfigPoolSummary, +} from '@console/internal/components/machine-config-pool'; +import type { PageComponentProps } from '@console/internal/components/utils'; +import { SectionHeading, WorkloadPausedAlert } from '@console/internal/components/utils'; +import { MachineConfigPoolModel, MachineModel } from '@console/internal/models'; +import type { MachineConfigPoolKind, MachineKind, NodeKind } from '@console/internal/module/k8s'; +import { LabelSelector } from '@console/internal/module/k8s/label-selector'; +import { getNodeMachineNameAndNamespace } from '@console/shared/src'; +import PaneBody from '@console/shared/src/components/layout/PaneBody'; + +const SkeletonDetails: FC = () => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+); + +const NodeMachine: ComponentType> = ({ obj }) => { + const { t } = useTranslation(); + const [machineName, machineNamespace] = getNodeMachineNameAndNamespace(obj); + const [machine, machineLoaded, machineLoadError] = useK8sWatchResource( + machineName && machineNamespace + ? { + groupVersionKind: { + kind: MachineModel.kind, + group: MachineModel.apiGroup, + version: MachineModel.apiVersion, + }, + name: machineName, + namespace: machineNamespace, + } + : null, + ); + + const [ + machineConfigPools, + machineConfigPoolsLoaded, + machineConfigPoolsLoadError, + ] = useK8sWatchResource({ + groupVersionKind: { + kind: MachineConfigPoolModel.kind, + group: MachineConfigPoolModel.apiGroup, + version: MachineConfigPoolModel.apiVersion, + }, + isList: true, + }); + + const machineConfigPool = useMemo(() => { + if (!machineConfigPoolsLoaded || !machineConfigPools?.length) { + return undefined; + } + return machineConfigPools.find((mcp) => { + if (!mcp.spec?.nodeSelector) { + return false; + } + const labelSelector = new LabelSelector(mcp.spec.nodeSelector); + return labelSelector.matches(obj); + }); + }, [machineConfigPools, machineConfigPoolsLoaded, obj]); + + const paused = machineConfigPool?.spec?.paused; + + return ( + <> + {machineLoadError ? ( +
{t('console-app~Error loading machine')}
+ ) : machineLoaded ? ( + machine ? ( + + ) : ( +
{t('console-app~Machine not found')}
+ ) + ) : ( + + )} + {machineConfigPoolsLoadError ? ( +
{t('console-app~Error loading machine config pool')}
+ ) : machineConfigPoolsLoaded ? ( + machineConfigPool ? ( + + {paused && ( + + )} + + + + + + + + + + + + ) : ( +
{t('console-app~Machine config pool not found')}
+ ) + ) : ( + + )} + + ); +}; + +export default NodeMachine; diff --git a/frontend/packages/console-app/src/components/nodes/configuration/__tests__/NodeConfiguration.spec.tsx b/frontend/packages/console-app/src/components/nodes/configuration/__tests__/NodeConfiguration.spec.tsx new file mode 100644 index 00000000000..a05e056939e --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/__tests__/NodeConfiguration.spec.tsx @@ -0,0 +1,402 @@ +import { useResolvedExtensions } from '@openshift/dynamic-plugin-sdk'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import NodeStorage from '@console/app/src/components/nodes/configuration/node-storage/NodeStorage'; +import type { NodeKind } from '@console/dynamic-plugin-sdk/src'; +import * as RouterUtils from '@console/internal/components/utils/router'; +import { useQueryParams } from '@console/shared/src'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { NodeConfiguration } from '../NodeConfiguration'; + +jest.mock('@console/shared/src', () => ({ + useQueryParams: jest.fn(), +})); + +jest.mock('../node-storage/NodeStorage', () => ({ + __esModule: true, + default: jest.fn(() => null), +})); + +jest.mock('../NodeMachine', () => ({ + __esModule: true, + default: jest.fn(() => null), +})); + +jest.mock('@console/internal/components/utils/router', () => ({ + ...jest.requireActual('@console/internal/components/utils/router'), + useQueryParamsMutator: jest.fn(), +})); + +jest.mock('@openshift/dynamic-plugin-sdk', () => ({ + ...jest.requireActual('@openshift/dynamic-plugin-sdk'), + useResolvedExtensions: jest.fn(), +})); + +const useQueryParamsMock = useQueryParams as jest.Mock; +const setQueryArgumentMock = jest.fn(); +const setAllQueryArgumentsMock = jest.fn(); +const mockUseResolvedExtensions = useResolvedExtensions as jest.Mock; + +describe('NodeConfiguration', () => { + const mockNode: NodeKind = { + apiVersion: 'v1', + kind: 'Node', + metadata: { + name: 'test-node', + uid: 'test-uid', + }, + spec: {}, + status: {}, + }; + + beforeEach(() => { + jest.clearAllMocks(); + // Mock useQueryParamsMutator + (RouterUtils.useQueryParamsMutator as jest.Mock).mockReturnValue({ + getQueryArgument: jest.fn(), + setQueryArgument: setQueryArgumentMock, + setQueryArguments: jest.fn(), + setAllQueryArguments: setAllQueryArgumentsMock, + removeQueryArgument: jest.fn(), + removeQueryArguments: jest.fn(), + setOrRemoveQueryArgument: jest.fn(), + }); + useQueryParamsMock.mockReturnValue(new URLSearchParams()); + mockUseResolvedExtensions.mockReturnValue([[], true]); + }); + + it('should render Storage tab by default', () => { + const mockQueryParams = new URLSearchParams(); + useQueryParamsMock.mockReturnValue(mockQueryParams); + + render(); + + expect(screen.getByText('Storage')).toBeInTheDocument(); + expect(screen.getByText('Machine')).toBeInTheDocument(); + + const tabs = screen.getAllByRole('tab'); + const storageTab = tabs.find((tab) => tab.textContent === 'Storage'); + const machineTab = tabs.find((tab) => tab.textContent === 'Machine'); + + expect(storageTab).toHaveAttribute('aria-selected', 'true'); + expect(machineTab).toHaveAttribute('aria-selected', 'false'); + }); + + it('should render Machine tab when activeTab query param is set', () => { + const mockQueryParams = new URLSearchParams('activeTab=machine'); + useQueryParamsMock.mockReturnValue(mockQueryParams); + + render(); + + const tabs = screen.getAllByRole('tab'); + const machineTab = tabs.find((tab) => tab.textContent === 'Machine'); + + expect(machineTab).toHaveAttribute('aria-selected', 'true'); + }); + + it('should update query argument when tab is clicked', () => { + const mockQueryParams = new URLSearchParams(); + useQueryParamsMock.mockReturnValue(mockQueryParams); + + render(); + + const machineTab = screen.getByText('Machine'); + fireEvent.click(machineTab); + + expect(setAllQueryArgumentsMock).toHaveBeenCalledWith({ activeTab: 'machine' }); + }); + + it('should render vertical tabs navigation', () => { + const mockQueryParams = new URLSearchParams(); + useQueryParamsMock.mockReturnValue(mockQueryParams); + + const { container } = render(); + + const tabsNav = container.querySelector('nav'); + expect(tabsNav).toBeInTheDocument(); + }); + + it('should have correct data-test-id attributes', () => { + const mockQueryParams = new URLSearchParams(); + useQueryParamsMock.mockReturnValue(mockQueryParams); + + const { container } = render(); + + expect(container.querySelector('[data-test-id="subnav-storage"]')).toBeInTheDocument(); + expect(container.querySelector('[data-test-id="subnav-machine"]')).toBeInTheDocument(); + }); + + it('should render tabs from plugin extensions with parentTab configuration', () => { + const mockExtensions = [ + { + type: 'console.tab/nodeSubNavTab', + properties: { + parentTab: 'configuration', + page: { + tabId: 'custom-tab', + name: 'Custom Tab', + priority: 60, + }, + component: jest.fn(() => 'CustomComponent'), + }, + }, + ]; + + mockUseResolvedExtensions.mockReturnValue([mockExtensions, true]); + + renderWithProviders(); + + expect(screen.getByRole('tab', { name: /Custom Tab/i })).toBeVisible(); + }); + + it('should filter out extensions with different parentTab values', () => { + const mockExtensions = [ + { + type: 'console.tab/nodeSubNavTab', + properties: { + parentTab: 'health', + page: { + tabId: 'health-tab', + name: 'Health Tab', + priority: 60, + }, + component: jest.fn(() => 'HealthComponent'), + }, + }, + { + type: 'console.tab/nodeSubNavTab', + properties: { + parentTab: 'workload', + page: { + tabId: 'workload-tab', + name: 'Workload Tab', + priority: 60, + }, + component: jest.fn(() => 'WorkloadComponent'), + }, + }, + ]; + + mockUseResolvedExtensions.mockReturnValue([mockExtensions, true]); + + renderWithProviders(); + + expect(screen.queryByRole('tab', { name: /Health Tab/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: /Workload Tab/i })).not.toBeInTheDocument(); + }); + + it('should sort tabs by priority in descending order', () => { + const mockExtensions = [ + { + type: 'console.tab/nodeSubNavTab', + properties: { + parentTab: 'configuration', + page: { + tabId: 'low-priority-tab', + name: 'Low Priority', + priority: 30, + }, + component: jest.fn(() => 'LowPriorityComponent'), + }, + }, + { + type: 'console.tab/nodeSubNavTab', + properties: { + parentTab: 'configuration', + page: { + tabId: 'high-priority-tab', + name: 'High Priority', + priority: 90, + }, + component: jest.fn(() => 'HighPriorityComponent'), + }, + }, + ]; + + mockUseResolvedExtensions.mockReturnValue([mockExtensions, true]); + + renderWithProviders(); + + const tabs = screen.getAllByRole('tab'); + const tabNames = tabs.map((tab) => within(tab).getByText(/\w+/).textContent); + + // Expected order: High Priority (90), Storage (70), Machine (50), Low Priority (30) + expect(tabNames).toEqual(['High Priority', 'Storage', 'Machine', 'Low Priority']); + }); + + it('should render component from plugin extension when tab is active', async () => { + const MockComponent = jest.fn(() => 'PluginComponent'); + const mockExtensions = [ + { + type: 'console.tab/nodeSubNavTab', + properties: { + parentTab: 'configuration', + page: { + tabId: 'plugin-tab', + name: 'Plugin Tab', + priority: 80, + }, + component: MockComponent, + }, + }, + ]; + + mockUseResolvedExtensions.mockReturnValue([mockExtensions, true]); + useQueryParamsMock.mockReturnValue(new URLSearchParams('activeTab=plugin-tab')); + + renderWithProviders(); + + expect(screen.getByText('PluginComponent')).toBeInTheDocument(); + expect(MockComponent).toHaveBeenCalledWith({ obj: mockNode }, {}); + }); + + it('should pass node object as obj prop to tab components', () => { + renderWithProviders(); + + expect(NodeStorage).toHaveBeenCalledWith({ obj: mockNode }, {}); + }); + + it('should handle multiple plugin extensions with same priority', () => { + const mockExtensions = [ + { + type: 'console.tab/nodeSubNavTab', + properties: { + parentTab: 'configuration', + page: { + tabId: 'tab-1', + name: 'Tab One', + priority: 60, + }, + component: jest.fn(() => 'ComponentOne'), + }, + }, + { + type: 'console.tab/nodeSubNavTab', + properties: { + parentTab: 'configuration', + page: { + tabId: 'tab-2', + name: 'Tab Two', + priority: 60, + }, + component: jest.fn(() => 'ComponentTwo'), + }, + }, + ]; + + mockUseResolvedExtensions.mockReturnValue([mockExtensions, true]); + + renderWithProviders(); + + expect(screen.getByRole('tab', { name: /Tab One/i })).toBeVisible(); + expect(screen.getByRole('tab', { name: /Tab Two/i })).toBeVisible(); + }); + + it('should handle tab names using nameKey for i18n translation', () => { + renderWithProviders(); + + // Storage and Machine tabs use nameKey which gets translated + expect(screen.getByRole('tab', { name: /Storage/i })).toBeVisible(); + expect(screen.getByRole('tab', { name: /Machine/i })).toBeVisible(); + }); + + it('should handle tab names using direct name property from extensions', () => { + const mockExtensions = [ + { + type: 'console.tab/nodeSubNavTab', + properties: { + parentTab: 'configuration', + page: { + tabId: 'direct-name-tab', + name: 'Direct Name Tab', + priority: 60, + }, + component: jest.fn(() => 'DirectNameComponent'), + }, + }, + ]; + + mockUseResolvedExtensions.mockReturnValue([mockExtensions, true]); + + renderWithProviders(); + + expect(screen.getByRole('tab', { name: /Direct Name Tab/i })).toBeVisible(); + }); + + it('should switch to plugin extension tab when clicked', async () => { + const user = userEvent.setup(); + const mockExtensions = [ + { + type: 'console.tab/nodeSubNavTab', + properties: { + parentTab: 'configuration', + page: { + tabId: 'clickable-tab', + name: 'Clickable Tab', + priority: 60, + }, + component: jest.fn(() => 'ClickableComponent'), + }, + }, + ]; + + mockUseResolvedExtensions.mockReturnValue([mockExtensions, true]); + + renderWithProviders(); + + const clickableTab = screen.getByRole('tab', { name: /Clickable Tab/i }); + await user.click(clickableTab); + + expect(setAllQueryArgumentsMock).toHaveBeenCalledWith({ activeTab: 'clickable-tab' }); + }); + + it('should render only configuration parentTab extensions and not other types', () => { + const mockExtensions = [ + { + type: 'console.tab/nodeSubNavTab', + properties: { + parentTab: 'configuration', + page: { + tabId: 'config-tab', + name: 'Config Tab', + priority: 60, + }, + component: jest.fn(() => 'ConfigComponent'), + }, + }, + { + type: 'console.tab/nodeSubNavTab', + properties: { + parentTab: 'health', + page: { + tabId: 'health-tab', + name: 'Health Tab', + priority: 60, + }, + component: jest.fn(() => 'HealthComponent'), + }, + }, + { + type: 'console.tab/nodeSubNavTab', + properties: { + parentTab: 'workload', + page: { + tabId: 'workload-tab', + name: 'Workload Tab', + priority: 60, + }, + component: jest.fn(() => 'WorkloadComponent'), + }, + }, + ]; + + mockUseResolvedExtensions.mockReturnValue([mockExtensions, true]); + + renderWithProviders(); + + // Only configuration tab should be visible + expect(screen.getByRole('tab', { name: /Config Tab/i })).toBeVisible(); + expect(screen.queryByRole('tab', { name: /Health Tab/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: /Workload Tab/i })).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/console-app/src/components/nodes/configuration/__tests__/NodeMachine.spec.tsx b/frontend/packages/console-app/src/components/nodes/configuration/__tests__/NodeMachine.spec.tsx new file mode 100644 index 00000000000..5bd80e939ac --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/__tests__/NodeMachine.spec.tsx @@ -0,0 +1,180 @@ +import { render, screen } from '@testing-library/react'; +import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks'; +import type { NodeKind } from '@console/internal/module/k8s'; +import { getNodeMachineNameAndNamespace } from '@console/shared/src'; +import NodeMachine from '../NodeMachine'; + +jest.mock('@console/dynamic-plugin-sdk/src/utils/k8s/hooks', () => ({ + useK8sWatchResource: jest.fn(), +})); + +jest.mock('@console/internal/components/machine', () => ({ + MachineDetails: jest.fn(({ obj }) =>
Machine details for {obj.metadata.name}
), +})); + +jest.mock('@console/internal/components/machine-config-pool', () => ({ + MachineConfigPoolCharacteristics: jest.fn(() =>
MachineConfigPool characteristics
), + MachineConfigPoolSummary: jest.fn(() =>
MachineConfigPool summary
), +})); + +jest.mock('@console/internal/components/utils', () => ({ + PageComponentProps: {}, + SectionHeading: jest.fn(({ text }) =>

{text}

), + WorkloadPausedAlert: jest.fn(() =>
Workload paused alert
), +})); + +jest.mock('@console/shared/src', () => ({ + getNodeMachineNameAndNamespace: jest.fn(), +})); + +const getNodeMachineNameAndNamespaceMock = getNodeMachineNameAndNamespace as jest.Mock; +const useK8sWatchResourceMock = useK8sWatchResource as jest.Mock; + +describe('NodeMachine', () => { + const mockNode: NodeKind = { + apiVersion: 'v1', + kind: 'Node', + metadata: { + name: 'test-node', + uid: 'test-uid', + labels: { + 'node-role.kubernetes.io/worker': '', + }, + }, + spec: {}, + status: {}, + }; + + const mockMachine = { + apiVersion: 'machine.openshift.io/v1beta1', + kind: 'Machine', + metadata: { + name: 'test-machine', + namespace: 'openshift-machine-api', + uid: 'machine-uid', + }, + spec: {}, + status: {}, + }; + + const mockMachineConfigPool = { + apiVersion: 'machineconfiguration.openshift.io/v1', + kind: 'MachineConfigPool', + metadata: { + name: 'worker', + uid: 'mcp-uid', + }, + spec: { + nodeSelector: { + matchLabels: { + 'node-role.kubernetes.io/worker': '', + }, + }, + paused: false, + }, + status: {}, + }; + + beforeEach(() => { + jest.clearAllMocks(); + getNodeMachineNameAndNamespaceMock.mockReturnValue(['test-machine', 'openshift-machine-api']); + }); + + it('should show loading skeleton when data is loading', () => { + useK8sWatchResourceMock.mockReturnValue([null, false, undefined]); + + const { container } = render(); + + expect(container.querySelector('[data-test="skeleton-detail-view"]')).toBeInTheDocument(); + }); + + it('should display error message when machine fails to load', () => { + useK8sWatchResourceMock + .mockReturnValueOnce([null, true, new Error('Failed to load')]) + .mockReturnValueOnce([[], true, undefined]); + + render(); + + expect(screen.getByText('Error loading machine')).toBeInTheDocument(); + }); + + it('should display message when machine is not found', () => { + useK8sWatchResourceMock + .mockReturnValueOnce([null, true, undefined]) + .mockReturnValueOnce([[], true, undefined]); + + render(); + + expect(screen.getByText('Machine not found')).toBeInTheDocument(); + }); + + it('should display machine details when machine is loaded', () => { + useK8sWatchResourceMock + .mockReturnValueOnce([mockMachine, true, undefined]) + .mockReturnValueOnce([[mockMachineConfigPool], true, undefined]); + + render(); + + expect(screen.getByText('Machine details for test-machine')).toBeInTheDocument(); + }); + + it('should display error message when machine config pool fails to load', () => { + useK8sWatchResourceMock + .mockReturnValueOnce([mockMachine, true, undefined]) + .mockReturnValueOnce([null, true, new Error('Failed to load')]); + + render(); + + expect(screen.getByText('Error loading machine config pool')).toBeInTheDocument(); + }); + + it('should display message when machine config pool is not found', () => { + useK8sWatchResourceMock + .mockReturnValueOnce([mockMachine, true, undefined]) + .mockReturnValueOnce([[], true, undefined]); + + render(); + + expect(screen.getByText('Machine config pool not found')).toBeInTheDocument(); + }); + + it('should display machine config pool details when loaded', () => { + useK8sWatchResourceMock + .mockReturnValueOnce([mockMachine, true, undefined]) + .mockReturnValueOnce([[mockMachineConfigPool], true, undefined]); + + render(); + + expect(screen.getByText('Configuration')).toBeInTheDocument(); + expect(screen.getByText('MachineConfigs')).toBeInTheDocument(); + expect(screen.getByText('MachineConfigPool summary')).toBeInTheDocument(); + expect(screen.getByText('MachineConfigPool characteristics')).toBeInTheDocument(); + }); + + it('should display paused alert when machine config pool is paused', () => { + const pausedMCP = { + ...mockMachineConfigPool, + spec: { + ...mockMachineConfigPool.spec, + paused: true, + }, + }; + + useK8sWatchResourceMock + .mockReturnValueOnce([mockMachine, true, undefined]) + .mockReturnValueOnce([[pausedMCP], true, undefined]); + + render(); + + expect(screen.getByText('Workload paused alert')).toBeInTheDocument(); + }); + + it('should not watch machine when node has no machine annotation', () => { + getNodeMachineNameAndNamespaceMock.mockReturnValue([null, null]); + useK8sWatchResourceMock.mockReturnValue([null, false, undefined]); + + render(); + + expect(useK8sWatchResourceMock).toHaveBeenCalledWith(null); + }); +}); diff --git a/frontend/packages/console-app/src/components/nodes/configuration/node-storage/LocalDisks.tsx b/frontend/packages/console-app/src/components/nodes/configuration/node-storage/LocalDisks.tsx new file mode 100644 index 00000000000..b30ff816fc8 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/node-storage/LocalDisks.tsx @@ -0,0 +1,98 @@ +import type { FC } from 'react'; +import { Title } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import { humanizeDecimalBytes } from '@console/internal/components/utils/units'; +import type { NodeKind } from '@console/internal/module/k8s'; +import { DASH, useDeepCompareMemoize } from '@console/shared/src'; +import { useWatchBareMetalHost } from '../../utils/NodeBareMetalUtils'; + +type LocalDisksProps = { + node: NodeKind; +}; + +export type BareMetalHostDisk = { + hctl?: string; + model: string; + name: string; + rotational: boolean; + serialNumber?: string; + sizeBytes?: number; + vendor?: string; +}; + +type LocalDiskRowProps = { + obj: BareMetalHostDisk; +}; + +const LocalDiskRow: FC = ({ obj }) => { + const { t } = useTranslation(); + const { string: size } = + obj.sizeBytes !== undefined ? humanizeDecimalBytes(obj.sizeBytes) : { string: DASH }; + + return ( + + {obj.name} + {size} + + {obj.rotational ? t('console-app~Rotational') : t('console-app~SSD')} + + {obj.model} + {obj.serialNumber ?? DASH} + {obj.vendor ?? DASH} + {obj.hctl ?? DASH} + + ); +}; + +const LocalDisks: FC = ({ node }) => { + const { t } = useTranslation(); + const [bareMetalHost, bareMetalHostLoaded, bareMetalHostLoadError] = useWatchBareMetalHost(node); + + const disks = useDeepCompareMemoize( + bareMetalHostLoaded && !bareMetalHostLoadError && bareMetalHost + ? bareMetalHost.status?.hardware?.storage ?? [] + : [], + ); + + return ( + <> + + <span>{t('console-app~Local disks')}</span> + + {!bareMetalHostLoaded ? ( +
+ ) : bareMetalHostLoadError ? ( + t('console-app~Unable to load local disks') + ) : ( +
+ + + + + + + + + + + + + + {disks.length === 0 ? ( + + + + ) : ( + disks.map((disk) => ) + )} + +
{t('console-app~Name')}{t('console-app~Size')}{t('console-app~Type')}{t('console-app~Model')}{t('console-app~Serial number')}{t('console-app~Vendor')}{t('console-app~HCTL')}
+ {t('console-app~No local disks found')} +
+
+ )} + + ); +}; + +export default LocalDisks; diff --git a/frontend/packages/console-app/src/components/nodes/configuration/node-storage/NodeStorage.tsx b/frontend/packages/console-app/src/components/nodes/configuration/node-storage/NodeStorage.tsx new file mode 100644 index 00000000000..b6380c63534 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/node-storage/NodeStorage.tsx @@ -0,0 +1,28 @@ +import type { ComponentType } from 'react'; +import { Flex, FlexItem } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import type { PageComponentProps } from '@console/internal/components/utils'; +import { SectionHeading } from '@console/internal/components/utils'; +import type { NodeKind } from '@console/internal/module/k8s'; +import PaneBody from '@console/shared/src/components/layout/PaneBody'; +import LocalDisks from './LocalDisks'; +import PersistentVolumes from './PersistentVolumes'; + +const NodeStorage: ComponentType> = ({ obj: node }) => { + const { t } = useTranslation(); + return ( + + + + + + + + + + + + ); +}; + +export default NodeStorage; diff --git a/frontend/packages/console-app/src/components/nodes/configuration/node-storage/PersistentVolumes.tsx b/frontend/packages/console-app/src/components/nodes/configuration/node-storage/PersistentVolumes.tsx new file mode 100644 index 00000000000..f1343ecf638 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/node-storage/PersistentVolumes.tsx @@ -0,0 +1,350 @@ +import type { FC } from 'react'; +import { useMemo } from 'react'; +import { Title } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import type { K8sResourceCommon } from '@console/dynamic-plugin-sdk/src'; +import { ResourceLink } from '@console/internal/components/utils'; +import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; +import { + PersistentVolumeClaimModel, + PersistentVolumeModel, + PodModel, + StorageClassModel, +} from '@console/internal/models'; +import type { + NodeKind, + PersistentVolumeClaimKind, + PersistentVolumeKind, + PodKind, +} from '@console/internal/module/k8s'; +import { DASH, getUID } from '@console/shared/src'; +import { + DataVolumeModel, + getCurrentPod, + getVMIPod, + useWatchVirtualMachineInstances, +} from '../../utils/NodeVmUtils'; +import { useAccessibleResources } from '../../utils/useAccessibleResources'; + +type NodePersistentVolumeData = { + persistentVolume: PersistentVolumeKind; + persistentVolumeClaim: PersistentVolumeClaimKind; + vmi?: K8sResourceCommon; +}; + +type PersistentVolumeRowProps = { + persistentVolumeData: NodePersistentVolumeData; + pods: PodKind[]; +}; + +const PersistentVolumeRow: FC = ({ persistentVolumeData, pods }) => { + const { t } = useTranslation(); + + const pod = useMemo(() => { + if (persistentVolumeData.vmi) { + return getVMIPod(persistentVolumeData.vmi, pods); + } + + const podsForPVC = pods?.filter((pvcPod) => + pvcPod.spec?.volumes?.find( + (volume) => + volume.persistentVolumeClaim?.claimName && + volume.persistentVolumeClaim.claimName === + persistentVolumeData.persistentVolumeClaim?.metadata.name, + ), + ); + return podsForPVC ? getCurrentPod(podsForPVC) : undefined; + }, [persistentVolumeData.vmi, persistentVolumeData.persistentVolumeClaim?.metadata.name, pods]); + + return ( + + + + + + {persistentVolumeData.persistentVolumeClaim ? ( + + ) : ( +
{t('console-app~No claim')}
+ )} + + + {persistentVolumeData.persistentVolumeClaim?.metadata.namespace ?? DASH} + + + {pod ? ( + + ) : ( + DASH + )} + + + {persistentVolumeData.persistentVolume.spec?.storageClassName ? ( + + ) : ( + t('console-app~None') + )} + + + {persistentVolumeData.persistentVolume.spec?.capacity?.storage ?? DASH} + + + ); +}; + +type PersistentVolumesProps = { + node: NodeKind; +}; + +const PersistentVolumes: FC = ({ node }) => { + const { t } = useTranslation(); + const [vms, vmsLoaded, vmsLoadError] = useWatchVirtualMachineInstances(node.metadata.name); + const [ + persistentVolumes, + persistentVolumesLoaded, + persistentVolumesLoadError, + ] = useK8sWatchResource({ + groupVersionKind: { + group: PersistentVolumeModel.apiGroup, + version: PersistentVolumeModel.apiVersion, + kind: PersistentVolumeModel.kind, + }, + isList: true, + }); + const [pvcs, pvcsLoaded, pvcsLoadError] = useK8sWatchResource({ + groupVersionKind: { + group: PersistentVolumeClaimModel.apiGroup, + version: PersistentVolumeClaimModel.apiVersion, + kind: PersistentVolumeClaimModel.kind, + }, + isList: true, + }); + const [dataVolumes, dataVolumesLoaded, dataVolumesLoadError] = useAccessibleResources< + K8sResourceCommon + >({ + groupVersionKind: { + group: DataVolumeModel.apiGroup, + version: DataVolumeModel.apiVersion, + kind: DataVolumeModel.kind, + }, + isList: true, + namespaced: true, + }); + const [pods, podsLoaded, podsLoadError] = useAccessibleResources({ + groupVersionKind: { + group: PodModel.apiGroup, + version: PodModel.apiVersion, + kind: PodModel.kind, + }, + isList: true, + namespaced: true, + fieldSelector: `spec.nodeName=${node.metadata.name}`, + }); + + const loadError = + persistentVolumesLoadError || + pvcsLoadError || + dataVolumesLoadError || + vmsLoadError || + podsLoadError; + const isLoading = + !persistentVolumesLoaded || !pvcsLoaded || !dataVolumesLoaded || !vmsLoaded || !podsLoaded; + + const vmPVCs = useMemo(() => { + if ( + persistentVolumesLoadError || + !persistentVolumesLoaded || + pvcsLoadError || + !pvcsLoaded || + dataVolumesLoadError || + !dataVolumesLoaded || + !vmsLoaded || + vmsLoadError + ) { + return []; + } + return ( + pvcs?.reduce((acc, persistentVolumeClaim) => { + const persistentVolume = persistentVolumes.find( + (pv) => + pv.spec?.claimRef?.name === persistentVolumeClaim.metadata.name && + pv.spec?.claimRef?.namespace === persistentVolumeClaim.metadata.namespace, + ); + if (!persistentVolume) { + return acc; + } + + const dataVolumeOwnerRef = persistentVolumeClaim.metadata.ownerReferences?.find( + (owner) => owner.kind === 'DataVolume', + ); + const dataVolumeOwner = + dataVolumeOwnerRef && + dataVolumes?.find( + (dv) => + dv.metadata.name === dataVolumeOwnerRef.name && + dv.metadata.namespace === persistentVolumeClaim.metadata.namespace, + ); + if (dataVolumeOwner) { + const vmOwner = dataVolumeOwner.metadata.ownerReferences?.find( + (ref) => ref.kind === 'VirtualMachine', + ); + const vmi = + vmOwner && + vms.find( + (vm) => + vm.metadata.name === vmOwner.name && + vm.metadata.namespace === dataVolumeOwner.metadata.namespace, + ); + if (vmi) { + acc.push({ + persistentVolume, + persistentVolumeClaim, + vmi, + }); + } + } + return acc; + }, []) ?? [] + ); + }, [ + dataVolumes, + dataVolumesLoadError, + dataVolumesLoaded, + persistentVolumes, + persistentVolumesLoadError, + persistentVolumesLoaded, + pvcs, + pvcsLoadError, + pvcsLoaded, + vms, + vmsLoaded, + vmsLoadError, + ]); + + const nodePVCs = useMemo(() => { + if (persistentVolumesLoadError || !persistentVolumesLoaded || pvcsLoadError || !pvcsLoaded) { + return []; + } + const nodePVs = + persistentVolumes?.filter( + (pv) => pv.metadata.labels?.['kubernetes.io/hostname'] === node.metadata.name, + ) ?? []; + return ( + nodePVs?.reduce((acc, persistentVolume) => { + const persistentVolumeClaim = pvcs.find( + (pvc) => + pvc.metadata.name === persistentVolume.spec?.claimRef?.name && + pvc.metadata.namespace === persistentVolume.spec?.claimRef?.namespace, + ); + if (persistentVolumeClaim) { + acc.push({ + persistentVolume, + persistentVolumeClaim, + }); + } + return acc; + }, []) ?? [] + ); + }, [ + node.metadata.name, + persistentVolumes, + persistentVolumesLoadError, + persistentVolumesLoaded, + pvcs, + pvcsLoadError, + pvcsLoaded, + ]); + + const nodePersistentVolumeData: NodePersistentVolumeData[] = useMemo(() => { + const seen = new Set(); + return [...vmPVCs, ...nodePVCs].filter((data) => { + const uid = getUID(data.persistentVolume); + if (seen.has(uid)) { + return false; + } + seen.add(uid); + return true; + }); + }, [nodePVCs, vmPVCs]); + + return ( + <> + + <span>{t('console-app~Mounted persistent volumes')}</span> + + {isLoading ? ( +
+ ) : loadError ? ( + t('console-app~Unable to load persistent volumes') + ) : ( +
+ + + + + + + + + + + + + {nodePersistentVolumeData.length === 0 ? ( + + + + ) : ( + nodePersistentVolumeData.map((persistentVolumeData) => ( + + )) + )} + +
{t('console-app~Name')}{t('console-app~PVC')}{t('console-app~Namespace')}{t('console-app~Pod')}{t('console-app~StorageClass')}{t('console-app~Capacity')}
+ {t('console-app~No persistent volumes found')} +
+
+ )} + + ); +}; + +export default PersistentVolumes; diff --git a/frontend/packages/console-app/src/components/nodes/configuration/node-storage/__tests__/LocalDisks.spec.tsx b/frontend/packages/console-app/src/components/nodes/configuration/node-storage/__tests__/LocalDisks.spec.tsx new file mode 100644 index 00000000000..9cda0c65c6f --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/node-storage/__tests__/LocalDisks.spec.tsx @@ -0,0 +1,164 @@ +import { render, screen } from '@testing-library/react'; +import type { K8sResourceKind } from '@console/dynamic-plugin-sdk/src'; +import type { NodeKind } from '@console/internal/module/k8s'; +import { useWatchBareMetalHost } from '../../../utils/NodeBareMetalUtils'; +import LocalDisks from '../LocalDisks'; + +jest.mock('@console/internal/components/utils', () => ({ + SectionHeading: jest.fn(({ text }) =>

{text}

), +})); + +jest.mock('@console/shared/src', () => ({ + useDeepCompareMemoize: jest.fn((value) => value), +})); + +jest.mock('@console/shared/src/components/layout/PaneBody', () => ({ + __esModule: true, + default: jest.fn(({ children }) =>
{children}
), +})); + +jest.mock('../../../utils/NodeBareMetalUtils', () => ({ + useWatchBareMetalHost: jest.fn(), +})); + +const useWatchBareMetalHostMock = useWatchBareMetalHost as jest.Mock; + +describe('LocalDisks', () => { + const mockNode: NodeKind = { + apiVersion: 'v1', + kind: 'Node', + metadata: { + name: 'test-node', + uid: 'test-uid', + }, + spec: {}, + status: {}, + }; + + const mockBareMetalHost: K8sResourceKind = { + apiVersion: 'metal3.io/v1alpha1', + kind: 'BareMetalHost', + metadata: { + name: 'test-host', + namespace: 'openshift-machine-api', + }, + status: { + hardware: { + storage: [ + { + name: '/dev/sda', + sizeBytes: 500000000000, + rotational: false, + model: 'Samsung SSD', + serialNumber: 'SN123456', + vendor: 'Samsung', + hctl: '0:0:0:0', + }, + { + name: '/dev/sdb', + sizeBytes: 1000000000000, + rotational: true, + model: 'WD HDD', + serialNumber: 'SN789012', + vendor: 'Western Digital', + hctl: '0:0:1:0', + }, + ], + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should show loading skeleton when data is loading', () => { + useWatchBareMetalHostMock.mockReturnValue([null, false, undefined]); + + const { container } = render(); + + expect(container.querySelector('.loading-skeleton--table')).toBeInTheDocument(); + }); + + it('should display error message when loading fails', () => { + useWatchBareMetalHostMock.mockReturnValue([null, true, new Error('Failed to load')]); + + render(); + + expect(screen.getByText('Unable to load local disks')).toBeInTheDocument(); + }); + + it('should display message when no disks are found', () => { + const emptyHost = { + ...mockBareMetalHost, + status: { + hardware: { + storage: [], + }, + }, + }; + useWatchBareMetalHostMock.mockReturnValue([emptyHost, true, undefined]); + + render(); + + expect(screen.getByText('No local disks found')).toBeInTheDocument(); + }); + + it('should display disk information in a table', () => { + useWatchBareMetalHostMock.mockReturnValue([mockBareMetalHost, true, undefined]); + + render(); + + expect(screen.getByText('Local disks')).toBeInTheDocument(); + expect(screen.getByText('/dev/sda')).toBeInTheDocument(); + expect(screen.getByText('/dev/sdb')).toBeInTheDocument(); + expect(screen.getByText('Samsung SSD')).toBeInTheDocument(); + expect(screen.getByText('WD HDD')).toBeInTheDocument(); + expect(screen.getByText('SN123456')).toBeInTheDocument(); + expect(screen.getByText('SN789012')).toBeInTheDocument(); + }); + + it('should display disk type correctly', () => { + useWatchBareMetalHostMock.mockReturnValue([mockBareMetalHost, true, undefined]); + + render(); + + const rows = screen.getAllByRole('row'); + expect(rows[1]).toHaveTextContent('SSD'); + expect(rows[2]).toHaveTextContent('Rotational'); + }); + + it('should display table headers', () => { + useWatchBareMetalHostMock.mockReturnValue([mockBareMetalHost, true, undefined]); + + render(); + + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Size')).toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); + expect(screen.getByText('Model')).toBeInTheDocument(); + expect(screen.getByText('Serial number')).toBeInTheDocument(); + expect(screen.getByText('Vendor')).toBeInTheDocument(); + expect(screen.getByText('HCTL')).toBeInTheDocument(); + }); + + it('should handle bare metal host without storage status', () => { + const hostWithoutStorage = { + ...mockBareMetalHost, + status: {}, + }; + useWatchBareMetalHostMock.mockReturnValue([hostWithoutStorage, true, undefined]); + + render(); + + expect(screen.getByText('No local disks found')).toBeInTheDocument(); + }); + + it('should handle missing bare metal host', () => { + useWatchBareMetalHostMock.mockReturnValue([null, true, undefined]); + + render(); + + expect(screen.getByText('No local disks found')).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/console-app/src/components/nodes/configuration/node-storage/__tests__/NodeStorage.spec.tsx b/frontend/packages/console-app/src/components/nodes/configuration/node-storage/__tests__/NodeStorage.spec.tsx new file mode 100644 index 00000000000..58c1c648b36 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/node-storage/__tests__/NodeStorage.spec.tsx @@ -0,0 +1,42 @@ +import { render, screen } from '@testing-library/react'; +import type { NodeKind } from '@console/internal/module/k8s'; +import NodeStorage from '../NodeStorage'; + +jest.mock('../LocalDisks', () => ({ + __esModule: true, + default: jest.fn(({ node }) =>
LocalDisks for {node.metadata.name}
), +})); + +jest.mock('../PersistentVolumes', () => ({ + __esModule: true, + default: jest.fn(({ node }) =>
PersistentVolumes for {node.metadata.name}
), +})); + +describe('NodeStorage', () => { + const mockNode: NodeKind = { + apiVersion: 'v1', + kind: 'Node', + metadata: { + name: 'test-node', + uid: 'test-uid', + }, + spec: {}, + status: {}, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render LocalDisks component', () => { + render(); + + expect(screen.getByText('LocalDisks for test-node')).toBeInTheDocument(); + }); + + it('should render PersistentVolumes component', () => { + render(); + + expect(screen.getByText('PersistentVolumes for test-node')).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/console-app/src/components/nodes/configuration/node-storage/__tests__/PersistentVolumes.spec.tsx b/frontend/packages/console-app/src/components/nodes/configuration/node-storage/__tests__/PersistentVolumes.spec.tsx new file mode 100644 index 00000000000..04711bc44ae --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/node-storage/__tests__/PersistentVolumes.spec.tsx @@ -0,0 +1,328 @@ +import { render, screen } from '@testing-library/react'; +import { useAccessibleResources } from '@console/app/src/components/nodes/utils/useAccessibleResources'; +import { useFlag } from '@console/dynamic-plugin-sdk/src/utils/flags'; +import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; +import type { NodeKind } from '@console/internal/module/k8s'; +import { + getCurrentPod, + getVMIPod, + useWatchVirtualMachineInstances, +} from '../../../utils/NodeVmUtils'; +import PersistentVolumes from '../PersistentVolumes'; + +jest.mock('react-redux', () => { + const ActualReactRedux = jest.requireActual('react-redux'); + return { + ...ActualReactRedux, + useSelector: jest.fn(), + useDispatch: jest.fn(), + }; +}); +jest.mock('../../../utils/NodeVmUtils', () => { + const ActualNodeVmUtils = jest.requireActual('../../../utils/NodeVmUtils'); + return { + ...ActualNodeVmUtils, + getCurrentPod: jest.fn(), + getVMIPod: jest.fn(), + useWatchVirtualMachineInstances: jest.fn(), + }; +}); + +jest.mock('@console/internal/components/utils', () => ({ + ResourceLink: jest.fn(({ name }) => {name}), +})); + +jest.mock('@console/internal/components/utils/headings', () => ({ + SectionHeading: jest.fn(({ text }) =>

{text}

), +})); + +jest.mock('@console/internal/components/utils/k8s-watch-hook', () => ({ + useK8sWatchResource: jest.fn(), +})); + +jest.mock('@console/shared/src', () => ({ + ...jest.requireActual('@console/shared/src'), + DASH: '-', + getUID: jest.fn((resource) => resource?.metadata?.uid), +})); + +jest.mock('@console/shared/src/components/layout/PaneBody', () => ({ + __esModule: true, + default: jest.fn(({ children }) =>
{children}
), +})); +jest.mock('@console/shared/src/hooks/flag', () => ({ + ...jest.requireActual('@console/shared/src/hooks/flag'), + useFlag: jest.fn(), +})); + +jest.mock('@console/dynamic-plugin-sdk/src/utils/flags', () => ({ + useFlag: jest.fn(), +})); + +jest.mock('@console/app/src/components/nodes/utils/useAccessibleResources', () => ({ + useAccessibleResources: jest.fn(), +})); + +const useK8sWatchResourceMock = useK8sWatchResource as jest.Mock; +const useAccessibleResourcesMock = useAccessibleResources as jest.Mock; +const useWatchVirtualMachineInstancesMock = useWatchVirtualMachineInstances as jest.Mock; +const getCurrentPodMock = getCurrentPod as jest.Mock; +const getVMIPodMock = getVMIPod as jest.Mock; +const useFlagMock = useFlag as jest.Mock; + +describe('PersistentVolumes', () => { + const mockNode: NodeKind = { + apiVersion: 'v1', + kind: 'Node', + metadata: { + name: 'test-node', + uid: 'node-uid', + }, + spec: {}, + status: {}, + }; + + const mockPV = { + apiVersion: 'v1', + kind: 'PersistentVolume', + metadata: { + name: 'pv-1', + uid: 'pv-uid-1', + labels: { + 'kubernetes.io/hostname': 'test-node', + }, + }, + spec: { + capacity: { + storage: '10Gi', + }, + claimRef: { + name: 'pvc-1', + namespace: 'test-namespace', + }, + storageClassName: 'standard', + }, + }; + + const mockPVC = { + apiVersion: 'v1', + kind: 'PersistentVolumeClaim', + metadata: { + name: 'pvc-1', + namespace: 'test-namespace', + uid: 'pvc-uid-1', + }, + spec: { + volumeName: 'pv-1', + }, + }; + + const mockPod = { + apiVersion: 'v1', + kind: 'Pod', + metadata: { + name: 'pod-1', + namespace: 'test-namespace', + uid: 'pod-uid-1', + }, + spec: { + nodeName: 'test-node', + volumes: [ + { + name: 'vol-1', + persistentVolumeClaim: { + claimName: 'pvc-1', + }, + }, + ], + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + useWatchVirtualMachineInstancesMock.mockReturnValue([[], true, undefined]); + useAccessibleResourcesMock.mockReturnValue([[], true, undefined]); + // Default implementation: getCurrentPod returns the first pod from the array + getCurrentPodMock.mockImplementation((pods) => pods?.[0]); + // Default implementation: getVMIPod returns undefined + getVMIPodMock.mockReturnValue(undefined); + }); + + it('should show loading skeleton when data is loading', () => { + useK8sWatchResourceMock.mockReturnValue([[], false, undefined]); + + const { container } = render(); + + expect(container.querySelector('.loading-skeleton--table')).toBeInTheDocument(); + }); + + it('should display error message when loading fails', () => { + useK8sWatchResourceMock + .mockReturnValueOnce([[], true, new Error('Failed to load')]) + .mockReturnValue([[], true, undefined]); + + render(); + + expect(screen.getByText('Unable to load persistent volumes')).toBeInTheDocument(); + }); + + it('should display message when no persistent volumes are found', () => { + useK8sWatchResourceMock.mockReturnValue([[], true, undefined]); + + render(); + + expect(screen.getByText('No persistent volumes found')).toBeInTheDocument(); + }); + + it('should display persistent volume information in a table', () => { + useK8sWatchResourceMock + .mockReturnValueOnce([[mockPV], true, undefined]) + .mockReturnValueOnce([[mockPVC], true, undefined]); + useAccessibleResourcesMock + .mockReturnValueOnce([[], true, undefined]) + .mockReturnValueOnce([[mockPod], true, undefined]); + + render(); + + expect(screen.getByText('Mounted persistent volumes')).toBeInTheDocument(); + expect(screen.getByText('pv-1')).toBeInTheDocument(); + expect(screen.getByText('pvc-1')).toBeInTheDocument(); + expect(screen.getByText('pod-1')).toBeInTheDocument(); + }); + + it('should display table headers', () => { + useK8sWatchResourceMock.mockReturnValue([[], true, undefined]); + + render(); + + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('PVC')).toBeInTheDocument(); + expect(screen.getByText('Namespace')).toBeInTheDocument(); + expect(screen.getByText('Pod')).toBeInTheDocument(); + expect(screen.getByText('StorageClass')).toBeInTheDocument(); + expect(screen.getByText('Capacity')).toBeInTheDocument(); + }); + + it('should filter persistent volumes by node hostname label', () => { + const pvOtherNode = { + ...mockPV, + metadata: { + ...mockPV.metadata, + name: 'pv-other', + labels: { + 'kubernetes.io/hostname': 'other-node', + }, + }, + }; + + useK8sWatchResourceMock + .mockReturnValueOnce([[mockPV, pvOtherNode], true, undefined]) + .mockReturnValueOnce([[mockPVC], true, undefined]); + useAccessibleResourcesMock + .mockReturnValueOnce([[], true, undefined]) + .mockReturnValueOnce([[mockPod], true, undefined]); + + render(); + + expect(screen.getByText('pv-1')).toBeInTheDocument(); + expect(screen.queryByText('pv-other')).not.toBeInTheDocument(); + }); + + it('should display dash when no pod is found', () => { + useK8sWatchResourceMock + .mockReturnValueOnce([[mockPV], true, undefined]) + .mockReturnValueOnce([[mockPVC], true, undefined]); + + const { container } = render(); + + const rows = container.querySelectorAll('tbody tr'); + expect(rows[0]).toHaveTextContent('-'); + }); + + it('should display "No persistent volumes found" when PVC is not found', () => { + const pvNoClaim = { + ...mockPV, + spec: { + ...mockPV.spec, + claimRef: undefined, + }, + }; + + useK8sWatchResourceMock + .mockReturnValueOnce([[pvNoClaim], true, undefined]) + .mockReturnValueOnce([[], true, undefined]); + + render(); + + expect(screen.getByText('No persistent volumes found')).toBeInTheDocument(); + }); + + it('should watch resources with correct field selector for pods', () => { + useK8sWatchResourceMock.mockReturnValue([[], true, undefined]); + + render(); + + const podWatchCall = useAccessibleResourcesMock.mock.calls.find((call) => + call[0]?.fieldSelector?.includes('spec.nodeName'), + ); + + expect(podWatchCall[0].fieldSelector).toBe('spec.nodeName=test-node'); + }); + + it('should handle VirtualMachine instances', () => { + useFlagMock.mockReturnValue(true); + useK8sWatchResourceMock.mockReset(); + useAccessibleResourcesMock.mockReset(); + const vmi = { + metadata: { + name: 'vm-1', + namespace: 'test-namespace', + uid: 'vmi-uid-1', + }, + }; + + const dataVolume = { + metadata: { + name: 'dv-1', + namespace: 'test-namespace', + ownerReferences: [ + { + kind: 'VirtualMachine', + name: 'vm-1', + }, + ], + }, + }; + + const pvcWithDV = { + ...mockPVC, + metadata: { + ...mockPVC.metadata, + ownerReferences: [ + { + kind: 'DataVolume', + name: 'dv-1', + }, + ], + }, + }; + + useWatchVirtualMachineInstancesMock.mockReturnValue([[vmi], true, undefined]); + + useK8sWatchResourceMock.mockImplementation((opts) => + opts?.groupVersionKind?.kind === 'PersistentVolume' + ? [[mockPV], true, undefined] + : [[pvcWithDV], true, undefined], + ); + + useAccessibleResourcesMock.mockImplementation((opts) => + opts?.groupVersionKind?.kind === 'DataVolume' + ? [[dataVolume], true, undefined] + : [[mockPod], true, undefined], + ); + + render(); + + expect(screen.getByText('pv-1')).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/console-app/src/components/nodes/node-dashboard/BareMetalInventoryItems.tsx b/frontend/packages/console-app/src/components/nodes/node-dashboard/BareMetalInventoryItems.tsx index e56b9ac0e24..fbaafc8d041 100644 --- a/frontend/packages/console-app/src/components/nodes/node-dashboard/BareMetalInventoryItems.tsx +++ b/frontend/packages/console-app/src/components/nodes/node-dashboard/BareMetalInventoryItems.tsx @@ -12,7 +12,7 @@ import { metricsFromBareMetalHosts, useIsBareMetalPluginActive, useWatchBareMetalHost, -} from '@console/app/src/components/nodes/NodeBareMetalUtils'; +} from '@console/app/src/components/nodes/utils/NodeBareMetalUtils'; import { resourcePathFromModel } from '@console/internal/components/utils/resource-link'; import { DASH } from '@console/shared/src'; import { InventoryItem } from '@console/shared/src/components/dashboard/inventory-card/InventoryItem'; diff --git a/frontend/packages/console-app/src/components/nodes/node-dashboard/InventoryCard.tsx b/frontend/packages/console-app/src/components/nodes/node-dashboard/InventoryCard.tsx index 02488dcf52f..9264edfad47 100644 --- a/frontend/packages/console-app/src/components/nodes/node-dashboard/InventoryCard.tsx +++ b/frontend/packages/console-app/src/components/nodes/node-dashboard/InventoryCard.tsx @@ -29,7 +29,7 @@ import { useIsKubevirtPluginActive, useWatchVirtualMachineInstances, VirtualMachineModel, -} from '../NodeVmUtils'; +} from '../utils/NodeVmUtils'; import { NodeDashboardContext } from './NodeDashboardContext'; export const NodeInventoryItem: FC = ({ nodeName, model, mapper }) => { diff --git a/frontend/packages/console-app/src/components/nodes/node-dashboard/__tests__/BareMetalInventoryItems.spec.tsx b/frontend/packages/console-app/src/components/nodes/node-dashboard/__tests__/BareMetalInventoryItems.spec.tsx index 75dc77c89b6..13c47ef2e60 100644 --- a/frontend/packages/console-app/src/components/nodes/node-dashboard/__tests__/BareMetalInventoryItems.spec.tsx +++ b/frontend/packages/console-app/src/components/nodes/node-dashboard/__tests__/BareMetalInventoryItems.spec.tsx @@ -1,15 +1,15 @@ import { render, screen } from '@testing-library/react'; +import type { K8sResourceKind } from '@console/dynamic-plugin-sdk/src'; +import type { NodeKind } from '@console/internal/module/k8s'; import { metricsFromBareMetalHosts, useIsBareMetalPluginActive, useWatchBareMetalHost, -} from '@console/app/src/components/nodes/NodeBareMetalUtils'; -import type { K8sResourceKind } from '@console/dynamic-plugin-sdk/src'; -import type { NodeKind } from '@console/internal/module/k8s'; +} from '../../utils/NodeBareMetalUtils'; import BareMetalInventoryItems from '../BareMetalInventoryItems'; import { NodeDashboardContext } from '../NodeDashboardContext'; -jest.mock('@console/app/src/components/nodes/NodeBareMetalUtils', () => ({ +jest.mock('../../utils/NodeBareMetalUtils', () => ({ BareMetalHostModel: { apiGroup: 'metal3.io', apiVersion: 'v1alpha1', diff --git a/frontend/packages/console-app/src/components/nodes/NodeBareMetalUtils.ts b/frontend/packages/console-app/src/components/nodes/utils/NodeBareMetalUtils.ts similarity index 91% rename from frontend/packages/console-app/src/components/nodes/NodeBareMetalUtils.ts rename to frontend/packages/console-app/src/components/nodes/utils/NodeBareMetalUtils.ts index 569ea4de801..4f9c5cb84c5 100644 --- a/frontend/packages/console-app/src/components/nodes/NodeBareMetalUtils.ts +++ b/frontend/packages/console-app/src/components/nodes/utils/NodeBareMetalUtils.ts @@ -1,3 +1,4 @@ +import { useAccessibleResources } from '@console/app/src/components/nodes/utils/useAccessibleResources'; import type { K8sGroupVersionKind, K8sResourceKind, @@ -5,7 +6,6 @@ import type { } from '@console/dynamic-plugin-sdk/src'; import type { NodeKind } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; import { useFlag } from '@console/dynamic-plugin-sdk/src/utils/flags'; -import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks'; import { MachineModel } from '@console/internal/models'; import type { K8sKind, MachineKind } from '@console/internal/module/k8s'; import { getName, getNodeMachineNameAndNamespace } from '@console/shared/src'; @@ -68,26 +68,27 @@ export const useWatchBareMetalHost = ( ): WatchK8sResult => { const isBareMetalPluginActive = useIsBareMetalPluginActive(); - const [bareMetalHosts, bareMetalHostsLoaded, bareMetalHostsLoadError] = useK8sWatchResource< - K8sResourceKind[] + const [bareMetalHosts, bareMetalHostsLoaded, bareMetalHostsLoadError] = useAccessibleResources< + K8sResourceKind >( isBareMetalPluginActive ? { - isList: true, groupVersionKind: BareMetalHostGroupVersionKind, + isList: true, + namespaced: true, } : undefined, ); - - const [machines, machinesLoaded, machinesLoadError] = useK8sWatchResource( + const [machines, machinesLoaded, machinesLoadError] = useAccessibleResources( isBareMetalPluginActive ? { - isList: true, groupVersionKind: { group: MachineModel.apiGroup, version: MachineModel.apiVersion, kind: MachineModel.kind, }, + isList: true, + namespaced: true, } : undefined, ); diff --git a/frontend/packages/console-app/src/components/nodes/NodeVmUtils.ts b/frontend/packages/console-app/src/components/nodes/utils/NodeVmUtils.ts similarity index 82% rename from frontend/packages/console-app/src/components/nodes/NodeVmUtils.ts rename to frontend/packages/console-app/src/components/nodes/utils/NodeVmUtils.ts index 1e3b7fb52cd..7c3babfb67c 100644 --- a/frontend/packages/console-app/src/components/nodes/NodeVmUtils.ts +++ b/frontend/packages/console-app/src/components/nodes/utils/NodeVmUtils.ts @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import type { K8sGroupVersionKind, K8sModel, @@ -6,8 +7,8 @@ import type { WatchK8sResult, } from '@console/dynamic-plugin-sdk/src'; import { useFlag } from '@console/dynamic-plugin-sdk/src/utils/flags'; -import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks'; import type { PodKind } from '@console/internal/module/k8s'; +import { useAccessibleResources } from './useAccessibleResources'; export const VirtualMachineModel: K8sModel = { label: 'VirtualMachine', @@ -22,6 +23,19 @@ export const VirtualMachineModel: K8sModel = { crd: true, }; +export const DataVolumeModel: K8sModel = { + abbr: 'DV', + apiGroup: 'cdi.kubevirt.io', + apiVersion: 'v1beta1', + crd: true, + id: 'datavolume', + kind: 'DataVolume', + label: 'DataVolume', + labelPlural: 'DataVolumes', + namespaced: true, + plural: 'datavolumes', +}; + // TODO: Remove VMI retrieval and VMs count column if/when the plugin is able to add the VMs count column export const VirtualMachineInstanceGroupVersionKind: K8sGroupVersionKind = { group: 'kubevirt.io', @@ -48,22 +62,27 @@ export const useWatchVirtualMachineInstances = ( nodeName?: string, ): WatchK8sResult => { const isKubevirtPluginActive = useIsKubevirtPluginActive(); - const [ virtualMachineInstances, virtualMachineInstancesLoaded, virtualMachineInstancesLoadError, - ] = useK8sWatchResource( + ] = useAccessibleResources( isKubevirtPluginActive ? { - isList: true, groupVersionKind: VirtualMachineInstanceGroupVersionKind, + isList: true, + namespaced: true, } : undefined, ); + const nodeVirtualMachineInstances = useMemo( + () => filterVirtualMachineInstancesByNode(virtualMachineInstances, nodeName), + [nodeName, virtualMachineInstances], + ); + return [ - filterVirtualMachineInstancesByNode(virtualMachineInstances, nodeName), + nodeVirtualMachineInstances, virtualMachineInstancesLoaded, virtualMachineInstancesLoadError, ]; diff --git a/frontend/packages/console-app/src/components/nodes/__tests__/NodeBareMetalUtils.spec.ts b/frontend/packages/console-app/src/components/nodes/utils/__tests__/NodeBareMetalUtils.spec.ts similarity index 89% rename from frontend/packages/console-app/src/components/nodes/__tests__/NodeBareMetalUtils.spec.ts rename to frontend/packages/console-app/src/components/nodes/utils/__tests__/NodeBareMetalUtils.spec.ts index 1ed21e6bb67..c60525d8a7b 100644 --- a/frontend/packages/console-app/src/components/nodes/__tests__/NodeBareMetalUtils.spec.ts +++ b/frontend/packages/console-app/src/components/nodes/utils/__tests__/NodeBareMetalUtils.spec.ts @@ -2,7 +2,10 @@ import { renderHook } from '@testing-library/react'; import type { K8sResourceKind } from '@console/dynamic-plugin-sdk/src'; import type { NodeKind } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; import { useFlag } from '@console/dynamic-plugin-sdk/src/utils/flags'; -import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks'; +import { + useK8sWatchResource, + useK8sWatchResources, +} from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks'; import type { MachineKind } from '@console/internal/module/k8s'; import { BAREMETAL_FLAG, @@ -20,10 +23,12 @@ jest.mock('@console/dynamic-plugin-sdk/src/utils/flags', () => ({ jest.mock('@console/dynamic-plugin-sdk/src/utils/k8s/hooks', () => ({ useK8sWatchResource: jest.fn(), + useK8sWatchResources: jest.fn(), })); const useFlagMock = useFlag as jest.Mock; const useK8sWatchResourceMock = useK8sWatchResource as jest.Mock; +const useK8sWatchResourcesMock = useK8sWatchResources as jest.Mock; describe('NodeBareMetalUtils', () => { describe('useIsBareMetalPluginActive', () => { @@ -179,6 +184,7 @@ describe('NodeBareMetalUtils', () => { it('should not watch resources when bare metal plugin is not active', () => { useFlagMock.mockReturnValue(false); useK8sWatchResourceMock.mockReturnValue([[], false, undefined]); + useK8sWatchResourcesMock.mockReturnValue({}); renderHook(() => useWatchBareMetalHost(node as NodeKind)); @@ -194,6 +200,7 @@ describe('NodeBareMetalUtils', () => { expect(useK8sWatchResourceMock).toHaveBeenCalledWith({ isList: true, groupVersionKind: BareMetalHostGroupVersionKind, + namespaced: true, }); }); @@ -220,9 +227,16 @@ describe('NodeBareMetalUtils', () => { }, ]; - useK8sWatchResourceMock - .mockReturnValueOnce([hosts, true, undefined]) - .mockReturnValueOnce([machines, true, undefined]); + useK8sWatchResourceMock.mockImplementation((initResource) => { + // useAccessibleResources calls useK8sWatchResource twice per resource: first with null (projects), then with initResource + if (!initResource) { + return [[], true, undefined]; + } + if (initResource.groupVersionKind === BareMetalHostGroupVersionKind) { + return [hosts, true, undefined]; + } + return [machines, true, undefined]; + }); const { result } = renderHook(() => useWatchBareMetalHost(node as NodeKind)); @@ -235,13 +249,12 @@ describe('NodeBareMetalUtils', () => { useFlagMock.mockReturnValue(true); const error = new Error('Failed to load'); - useK8sWatchResourceMock - .mockReturnValueOnce([[], false, error]) - .mockReturnValueOnce([[], false, undefined]); + useK8sWatchResourceMock.mockReturnValue([[], false, error]); + useK8sWatchResourcesMock.mockReturnValue({}); const { result } = renderHook(() => useWatchBareMetalHost(node as NodeKind)); - expect(result.current[0]).toBeUndefined(); + expect(result.current[0]).toBe(undefined); expect(result.current[1]).toBe(false); expect(result.current[2]).toBe(error); }); diff --git a/frontend/packages/console-app/src/components/nodes/__tests__/NodeVmUtils.spec.ts b/frontend/packages/console-app/src/components/nodes/utils/__tests__/NodeVmUtils.spec.ts similarity index 96% rename from frontend/packages/console-app/src/components/nodes/__tests__/NodeVmUtils.spec.ts rename to frontend/packages/console-app/src/components/nodes/utils/__tests__/NodeVmUtils.spec.ts index ea7cbc2d38a..15f6467db6b 100644 --- a/frontend/packages/console-app/src/components/nodes/__tests__/NodeVmUtils.spec.ts +++ b/frontend/packages/console-app/src/components/nodes/utils/__tests__/NodeVmUtils.spec.ts @@ -1,7 +1,10 @@ import { renderHook } from '@testing-library/react'; import type { K8sResourceCommon, K8sResourceKind } from '@console/dynamic-plugin-sdk/src'; import { useFlag } from '@console/dynamic-plugin-sdk/src/utils/flags'; -import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks'; +import { + useK8sWatchResource, + useK8sWatchResources, +} from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks'; import type { PodKind } from '@console/internal/module/k8s'; import { filterVirtualMachineInstancesByNode, @@ -19,10 +22,12 @@ jest.mock('@console/dynamic-plugin-sdk/src/utils/flags', () => ({ jest.mock('@console/dynamic-plugin-sdk/src/utils/k8s/hooks', () => ({ useK8sWatchResource: jest.fn(), + useK8sWatchResources: jest.fn(), })); const useFlagMock = useFlag as jest.Mock; const useK8sWatchResourceMock = useK8sWatchResource as jest.Mock; +const useK8sWatchResourcesMock = useK8sWatchResources as jest.Mock; describe('NodeVmUtils', () => { beforeEach(() => { @@ -301,6 +306,7 @@ describe('NodeVmUtils', () => { it('should not watch resources when kubevirt plugin is not active', () => { useFlagMock.mockReturnValue(false); useK8sWatchResourceMock.mockReturnValue([[], false, undefined]); + useK8sWatchResourcesMock.mockReturnValue({}); renderHook(() => useWatchVirtualMachineInstances('node-1')); @@ -310,12 +316,14 @@ describe('NodeVmUtils', () => { it('should watch VMIs when kubevirt plugin is active', () => { useFlagMock.mockReturnValue(true); useK8sWatchResourceMock.mockReturnValue([[], false, undefined]); + useK8sWatchResourcesMock.mockReturnValue({}); renderHook(() => useWatchVirtualMachineInstances('node-1')); expect(useK8sWatchResourceMock).toHaveBeenCalledWith({ isList: true, groupVersionKind: VirtualMachineInstanceGroupVersionKind, + namespaced: true, }); }); diff --git a/frontend/packages/console-app/src/components/nodes/utils/useAccessibleResources.ts b/frontend/packages/console-app/src/components/nodes/utils/useAccessibleResources.ts new file mode 100644 index 00000000000..22cb4383a83 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/utils/useAccessibleResources.ts @@ -0,0 +1,87 @@ +import { useMemo } from 'react'; +import type { + K8sResourceCommon, + K8sResourceKind, + WatchK8sResource, + WatchK8sResources, + WatchK8sResult, +} from '@console/dynamic-plugin-sdk/src'; +import { useFlag } from '@console/dynamic-plugin-sdk/src/utils/flags'; +import { + useK8sWatchResource, + useK8sWatchResources, +} from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks'; +import { ProjectModel } from '@console/internal/models'; +import { FLAGS, getName } from '@console/shared/src'; + +export const useAccessibleResources = ( + initResource?: WatchK8sResource, +): WatchK8sResult => { + const isAdmin = useFlag(FLAGS.CAN_LIST_NS); + + const [projectsData, projectsLoaded, projectsLoadError] = useK8sWatchResource< + K8sResourceCommon[] + >( + !isAdmin && initResource + ? { + groupVersionKind: { + group: ProjectModel.apiGroup, + version: ProjectModel.apiVersion, + kind: ProjectModel.kind, + }, + isList: true, + } + : null, + ); + + const projectsNames = useMemo( + () => (!isAdmin && projectsLoaded ? projectsData?.map(getName) : []), + [isAdmin, projectsData, projectsLoaded], + ); + + const initResources: WatchK8sResources = useMemo(() => { + const resources = {}; + projectsNames.forEach((namespace) => { + resources[namespace] = { ...initResource, namespace, namespaced: true }; + }); + return resources; + }, [initResource, projectsNames]); + + const namespacedResources = useK8sWatchResources(initResources); + + const [resources, resourcesLoaded, resourcesLoadError] = useK8sWatchResource( + initResource && isAdmin ? initResource : undefined, + ); + + return useMemo(() => { + if (isAdmin) { + return [resources, resourcesLoaded, resourcesLoadError]; + } + + const namespacedResults = Object.values(namespacedResources); + const loaded = + projectsLoaded && namespacedResults.every((results) => results.loaded || results.loadError); + + if (!loaded) { + return [[], false, undefined]; + } + + const loadError = + projectsLoadError || namespacedResults.find((results) => results.loadError)?.loadError; + + const allResources = namespacedResults + .filter((results) => !results.loadError) + .map((results) => results.data ?? []) + .flat(); + + return [allResources, true, loadError]; + }, [ + isAdmin, + namespacedResources, + projectsLoadError, + projectsLoaded, + resources, + resourcesLoadError, + resourcesLoaded, + ]); +}; diff --git a/frontend/packages/console-dynamic-plugin-sdk/docs/console-extensions.md b/frontend/packages/console-dynamic-plugin-sdk/docs/console-extensions.md index 489fce50644..54cc3c8d7b8 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/docs/console-extensions.md +++ b/frontend/packages/console-dynamic-plugin-sdk/docs/console-extensions.md @@ -58,28 +58,29 @@ 56. [console.storage-provider](#consolestorage-provider) 57. [console.tab](#consoletab) 58. [console.tab/horizontalNav](#consoletabhorizontalNav) -59. [console.telemetry/listener](#consoletelemetrylistener) -60. [console.topology/adapter/build](#consoletopologyadapterbuild) -61. [console.topology/adapter/network](#consoletopologyadapternetwork) -62. [console.topology/adapter/pod](#consoletopologyadapterpod) -63. [console.topology/component/factory](#consoletopologycomponentfactory) -64. [console.topology/create/connector](#consoletopologycreateconnector) -65. [console.topology/data/factory](#consoletopologydatafactory) -66. [console.topology/decorator/provider](#consoletopologydecoratorprovider) -67. [console.topology/details/resource-alert](#consoletopologydetailsresource-alert) -68. [console.topology/details/resource-link](#consoletopologydetailsresource-link) -69. [console.topology/details/tab](#consoletopologydetailstab) -70. [console.topology/details/tab-section](#consoletopologydetailstab-section) -71. [console.topology/display/filters](#consoletopologydisplayfilters) -72. [console.topology/relationship/provider](#consoletopologyrelationshipprovider) -73. [console.user-preference/group](#consoleuser-preferencegroup) -74. [console.user-preference/item](#consoleuser-preferenceitem) -75. [console.yaml-template](#consoleyaml-template) -76. [dev-console.add/action](#dev-consoleaddaction) -77. [dev-console.add/action-group](#dev-consoleaddaction-group) -78. [dev-console.import/environment](#dev-consoleimportenvironment) -79. [DEPRECATED] [console.dashboards/overview/detail/item](#consoledashboardsoverviewdetailitem) -80. [DEPRECATED] [console.page/resource/tab](#consolepageresourcetab) +59. [console.tab/nodeSubNavTab](#consoletabnodeSubNavTab) +60. [console.telemetry/listener](#consoletelemetrylistener) +61. [console.topology/adapter/build](#consoletopologyadapterbuild) +62. [console.topology/adapter/network](#consoletopologyadapternetwork) +63. [console.topology/adapter/pod](#consoletopologyadapterpod) +64. [console.topology/component/factory](#consoletopologycomponentfactory) +65. [console.topology/create/connector](#consoletopologycreateconnector) +66. [console.topology/data/factory](#consoletopologydatafactory) +67. [console.topology/decorator/provider](#consoletopologydecoratorprovider) +68. [console.topology/details/resource-alert](#consoletopologydetailsresource-alert) +69. [console.topology/details/resource-link](#consoletopologydetailsresource-link) +70. [console.topology/details/tab](#consoletopologydetailstab) +71. [console.topology/details/tab-section](#consoletopologydetailstab-section) +72. [console.topology/display/filters](#consoletopologydisplayfilters) +73. [console.topology/relationship/provider](#consoletopologyrelationshipprovider) +74. [console.user-preference/group](#consoleuser-preferencegroup) +75. [console.user-preference/item](#consoleuser-preferenceitem) +76. [console.yaml-template](#consoleyaml-template) +77. [dev-console.add/action](#dev-consoleaddaction) +78. [dev-console.add/action-group](#dev-consoleaddaction-group) +79. [dev-console.import/environment](#dev-consoleimportenvironment) +80. [DEPRECATED] [console.dashboards/overview/detail/item](#consoledashboardsoverviewdetailitem) +81. [DEPRECATED] [console.page/resource/tab](#consolepageresourcetab) --- @@ -1062,6 +1063,22 @@ This extension can be used to add a tab on the resource details page. --- +## `console.tab/nodeSubNavTab` + +### Summary + +This extension can be used to add a tab on the sub-tabs for a Nodes details tab. + +### Properties + +| Name | Value Type | Optional | Description | +| ---- | ---------- | -------- | ----------- | +| `parentTab` | `'configuration' \| 'workload'` | no | Which detail tab to add the sub-tab to. | +| `page` | `{ tabId: string; name: string; priority: number; }` | no | The page to be show in node sub tabs. It takes tab name as name and priority of the tab.
Note: Tabs are shown in priority order from highest to lowest. Current node tab priorities are:
configuration:
Storage: 70
Machine: 50
workload:
Pods: 30 | +| `component` | `CodeRef>>` | no | The component to be rendered when the route matches. | + +--- + ## `console.telemetry/listener` ### Summary diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/extensions/index.ts b/frontend/packages/console-dynamic-plugin-sdk/src/extensions/index.ts index a6bcba5470f..04bf5729926 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/src/extensions/index.ts +++ b/frontend/packages/console-dynamic-plugin-sdk/src/extensions/index.ts @@ -17,6 +17,7 @@ export * from './file-upload'; export * from './horizontal-nav-tabs'; export * from './import-environments'; export * from './navigation'; +export * from './node-subnav-tabs'; export * from './notification-alert'; export * from './pages'; export * from './perspectives'; diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/extensions/node-subnav-tabs.ts b/frontend/packages/console-dynamic-plugin-sdk/src/extensions/node-subnav-tabs.ts new file mode 100644 index 00000000000..d09a6332dbd --- /dev/null +++ b/frontend/packages/console-dynamic-plugin-sdk/src/extensions/node-subnav-tabs.ts @@ -0,0 +1,34 @@ +import type { ComponentType } from 'react'; +import type { Extension, CodeRef } from '../types'; +import type { K8sResourceCommon } from './console-types'; + +export type SubPageComponentProps = { + obj: R; +}; + +/** This extension can be used to add a tab on the sub-tabs for a Nodes details tab. */ +export type NodeSubNavTab = Extension< + 'console.tab/nodeSubNavTab', + { + /** Which detail tab to add the sub-tab to. */ + parentTab: 'configuration' | 'workload'; + /** The page to be show in node sub tabs. It takes tab name as name and priority of the tab. + * Note: Tabs are shown in priority order from highest to lowest. Current node tab priorities are: + * configuration: + * Storage: 70 + * Machine: 50 + * workload: + * Pods: 30 + */ + page: { + tabId: string; + name: string; + priority: number; + }; + /** The component to be rendered when the route matches. */ + component: CodeRef>; + } +>; + +export const isNodeSubNavTab = (e: Extension): e is NodeSubNavTab => + e.type === 'console.tab/nodeSubNavTab'; diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/schema/console-extensions.ts b/frontend/packages/console-dynamic-plugin-sdk/src/schema/console-extensions.ts index f3fe5ff904f..949045d4bb5 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/src/schema/console-extensions.ts +++ b/frontend/packages/console-dynamic-plugin-sdk/src/schema/console-extensions.ts @@ -43,6 +43,7 @@ import type { Separator, NavSection, } from '../extensions/navigation'; +import type { NodeSubNavTab } from '../extensions/node-subnav-tabs'; import type { AlertAction } from '../extensions/notification-alert'; import type { StandaloneRoutePage, @@ -131,6 +132,7 @@ export type SupportedExtension = | UserPreferenceItem | Perspective | HorizontalNavTab + | NodeSubNavTab | NavTab | ClusterOverviewInventoryItem | ClusterOverviewUtilizationItem diff --git a/frontend/public/components/machine-config-pool.tsx b/frontend/public/components/machine-config-pool.tsx index b04374a982e..2ae63bbc98a 100644 --- a/frontend/public/components/machine-config-pool.tsx +++ b/frontend/public/components/machine-config-pool.tsx @@ -84,7 +84,9 @@ const getMachineConfigPoolUpdateStatus = (mcp: MachineConfigPoolKind) => { return null; }; -const MachineConfigPoolCharacteristics: FC = ({ obj }) => { +export const MachineConfigPoolCharacteristics: FC = ({ + obj, +}) => { const configuration = _.get(obj, 'status.configuration'); const maxUnavailable = _.get(obj, 'spec.maxUnavailable', 1); const { t } = useTranslation(); @@ -200,7 +202,7 @@ const MachineConfigPoolCounts: FC = ({ obj }) => { ); }; -const MachineConfigPoolSummary: FC = ({ obj }) => { +export const MachineConfigPoolSummary: FC = ({ obj }) => { const machineConfigSelector = _.get(obj, 'spec.machineConfigSelector'); const { t } = useTranslation(); return ( diff --git a/frontend/public/components/machine.tsx b/frontend/public/components/machine.tsx index 080acc61152..1e3436e84ed 100644 --- a/frontend/public/components/machine.tsx +++ b/frontend/public/components/machine.tsx @@ -110,7 +110,7 @@ const getDataViewRows = (data: { obj: MachineKind }[], columns: TableColumn = ({ obj }: { obj: MachineKind }) => { +export const MachineDetails: FC = ({ obj, hideConditions }) => { const nodeName = getMachineNodeName(obj); const machineRole = getMachineRole(obj); const instanceType = getMachineInstanceType(obj); @@ -180,10 +180,12 @@ const MachineDetails: FC = ({ obj }: { obj: MachineKind }) - - - - + {!hideConditions ? ( + + + + + ) : null} ); }; @@ -349,6 +351,7 @@ export const MachineDetailsPage: FC = (props) => ( export type MachineDetailsProps = { obj: MachineKind; + hideConditions?: boolean; }; export type MachinePageProps = {