diff --git a/frontend/packages/console-app/locales/en/console-app.json b/frontend/packages/console-app/locales/en/console-app.json index ce594b3c6a8..7e2d9ce2039 100644 --- a/frontend/packages/console-app/locales/en/console-app.json +++ b/frontend/packages/console-app/locales/en/console-app.json @@ -392,9 +392,6 @@ "Inventory": "Inventory", "Images": "Images", "Image": "Image", - "Virtual machines": "Virtual machines", - "This count reflects your access permissions and might not include all virtual machines.": "This count reflects your access permissions and might not include all virtual machines.", - "Virtual machine": "Virtual machine", "Health checks": "Health checks", "See details": "See details", "{{ cpuMessage }}": "{{ cpuMessage }}", @@ -412,6 +409,9 @@ "Utilization": "Utilization", "Network transfer": "Network transfer", "Pod count": "Pod count", + "Virtual machines": "Virtual machines", + "This count reflects your access permissions and might not include all virtual machines.": "This count reflects your access permissions and might not include all virtual machines.", + "Virtual machine": "Virtual machine", "Node conditions": "Node conditions", "Reason": "Reason", "Updated": "Updated", 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 6a2331c5961..d41b50da57b 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 @@ -11,8 +11,9 @@ import { DescriptionListTerm, } from '@patternfly/react-core'; import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router'; import BareMetalInventoryItems from '@console/app/src/components/nodes/node-dashboard/BareMetalInventoryItems'; +import VirtualMachinesInventoryItems from '@console/app/src/components/nodes/node-dashboard/VirtualMachinesInventoryItems'; +import { FLAG_NODE_MGMT_V1 } from '@console/app/src/consts'; import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; import { resourcePathFromModel } from '@console/internal/components/utils/resource-link'; import { PodModel, NodeModel } from '@console/internal/models'; @@ -24,9 +25,7 @@ import { ResourceInventoryItem, } from '@console/shared/src/components/dashboard/inventory-card/InventoryItem'; import { getPodStatusGroups } from '@console/shared/src/components/dashboard/inventory-card/utils'; -import { DescriptionListTermHelp } from '@console/shared/src/components/description-list/DescriptionListTermHelp'; -import { useIsKubevirtPluginActive } from '../../../utils/kubevirt'; -import { useWatchVirtualMachineInstances, VirtualMachineModel } from '../NodeVmUtils'; +import { useFlag } from '@console/shared/src/hooks/useFlag'; import { NodeDashboardContext } from './NodeDashboardContext'; export const NodeInventoryItem: FC = ({ nodeName, model, mapper }) => { @@ -56,9 +55,7 @@ export const NodeInventoryItem: FC = ({ nodeName, model, const InventoryCard: FC = () => { const { obj } = useContext(NodeDashboardContext); const { t } = useTranslation(); - - const showVms = useIsKubevirtPluginActive(); - const [vms, vmsLoaded, vmsLoadError] = useWatchVirtualMachineInstances(obj.metadata.name); + const nodeMgmtV1Enabled = useFlag(FLAG_NODE_MGMT_V1); return ( @@ -89,32 +86,12 @@ const InventoryCard: FC = () => { /> - - {showVms ? ( - - - - - - - - - ) : null} + {nodeMgmtV1Enabled && ( + <> + + + + )} diff --git a/frontend/packages/console-app/src/components/nodes/node-dashboard/VirtualMachinesInventoryItems.tsx b/frontend/packages/console-app/src/components/nodes/node-dashboard/VirtualMachinesInventoryItems.tsx new file mode 100644 index 00000000000..ae2a915c8c1 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/node-dashboard/VirtualMachinesInventoryItems.tsx @@ -0,0 +1,54 @@ +import type { FC } from 'react'; +import { useContext } from 'react'; +import { DescriptionListDescription, DescriptionListGroup } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router'; +import { + useWatchVirtualMachineInstances, + VirtualMachineModel, +} from '@console/app/src/components/nodes/NodeVmUtils'; +import { useIsKubevirtPluginActive } from '@console/app/src/utils/kubevirt'; +import { resourcePathFromModel } from '@console/internal/components/utils/resource-link'; +import { InventoryItem } from '@console/shared/src/components/dashboard/inventory-card/InventoryItem'; +import { DescriptionListTermHelp } from '@console/shared/src/components/description-list/DescriptionListTermHelp'; +import { NodeDashboardContext } from './NodeDashboardContext'; + +const VirtualMachinesInventoryItems: FC = () => { + const { obj } = useContext(NodeDashboardContext); + const { t } = useTranslation(); + const showVms = useIsKubevirtPluginActive(); + + const [vms, vmsLoaded, vmsLoadError] = useWatchVirtualMachineInstances(obj.metadata.name); + + if (!showVms) { + return null; + } + + return ( + + + + + + + + + ); +}; + +export default VirtualMachinesInventoryItems; diff --git a/frontend/packages/console-app/src/components/nodes/node-dashboard/__tests__/InventoryCard.spec.tsx b/frontend/packages/console-app/src/components/nodes/node-dashboard/__tests__/InventoryCard.spec.tsx new file mode 100644 index 00000000000..4b0d1520bd1 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/node-dashboard/__tests__/InventoryCard.spec.tsx @@ -0,0 +1,128 @@ +import { render, screen } from '@testing-library/react'; +import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; +import type { NodeKind } from '@console/internal/module/k8s'; +import { useFlag } from '@console/shared/src/hooks/useFlag'; +import BareMetalInventoryItems from '../BareMetalInventoryItems'; +import InventoryCard from '../InventoryCard'; +import { NodeDashboardContext } from '../NodeDashboardContext'; +import VirtualMachinesInventoryItems from '../VirtualMachinesInventoryItems'; + +jest.mock('@console/shared/src/hooks/useFlag', () => ({ + useFlag: jest.fn(), +})); + +jest.mock('@console/internal/components/utils/k8s-watch-hook', () => ({ + useK8sWatchResource: jest.fn(), +})); + +// Mock child components using jest.fn +jest.mock('@console/app/src/components/nodes/node-dashboard/BareMetalInventoryItems', () => ({ + __esModule: true, + default: jest.fn(() => null), +})); + +jest.mock('@console/app/src/components/nodes/node-dashboard/VirtualMachinesInventoryItems', () => ({ + __esModule: true, + default: jest.fn(() => null), +})); + +// Mock InventoryItem components +jest.mock('@console/shared/src/components/dashboard/inventory-card/InventoryItem', () => ({ + InventoryItem: ({ count }) => `Images: ${count || 0}`, + ResourceInventoryItem: () => 'Pod Inventory', +})); + +const useFlagMock = useFlag as jest.Mock; +const useK8sWatchResourceMock = useK8sWatchResource as jest.Mock; +const BareMetalInventoryItemsMock = BareMetalInventoryItems as jest.Mock; +const VirtualMachinesInventoryItemsMock = VirtualMachinesInventoryItems as jest.Mock; + +describe('InventoryCard', () => { + const mockNode: NodeKind = { + apiVersion: 'v1', + kind: 'Node', + metadata: { + name: 'test-node', + uid: 'node-uid', + }, + spec: {}, + status: { + images: [{ names: ['image1'] }, { names: ['image2'] }], + }, + }; + + const renderWithContext = (node: NodeKind = mockNode) => { + return render( + {}, + setMemoryLimit: () => {}, + setHealthCheck: () => {}, + }} + > + + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + useK8sWatchResourceMock.mockReturnValue([[], true, undefined]); + }); + + it('should render the inventory card with title', () => { + useFlagMock.mockReturnValue(false); + renderWithContext(); + + expect(screen.getByText('Inventory')).toBeVisible(); + }); + + it('should render Pod and Image inventory items when NODE_MGMT_V1 flag state is false', () => { + useFlagMock.mockReturnValue(false); + renderWithContext(); + + expect(screen.getByText('Pods')).toBeVisible(); + expect(screen.getByText('Pod Inventory')).toBeVisible(); + expect(screen.getByText('Images')).toBeVisible(); + expect(screen.getByText('Images: 2')).toBeVisible(); + }); + + it('should render Pod and Image inventory items when NODE_MGMT_V1 flag state is true', () => { + useFlagMock.mockReturnValue(true); + renderWithContext(); + + expect(screen.getByText('Pods')).toBeVisible(); + expect(screen.getByText('Pod Inventory')).toBeVisible(); + expect(screen.getByText('Images')).toBeVisible(); + expect(screen.getByText('Images: 2')).toBeVisible(); + }); + + it('should not render BareMetalInventoryItems when NODE_MGMT_V1 flag is off', () => { + useFlagMock.mockReturnValue(false); + renderWithContext(); + + expect(BareMetalInventoryItemsMock).not.toHaveBeenCalled(); + }); + + it('should not render VirtualMachinesInventoryItems when NODE_MGMT_V1 flag is off', () => { + useFlagMock.mockReturnValue(false); + renderWithContext(); + + expect(VirtualMachinesInventoryItemsMock).not.toHaveBeenCalled(); + }); + + it('should render BareMetalInventoryItems when NODE_MGMT_V1 flag is on', () => { + useFlagMock.mockReturnValue(true); + renderWithContext(); + + expect(BareMetalInventoryItemsMock).toHaveBeenCalled(); + }); + + it('should render VirtualMachinesInventoryItems when NODE_MGMT_V1 flag is on', () => { + useFlagMock.mockReturnValue(true); + renderWithContext(); + + expect(VirtualMachinesInventoryItemsMock).toHaveBeenCalled(); + }); +}); diff --git a/frontend/packages/console-app/src/components/nodes/node-dashboard/__tests__/VirtualMachinesInventoryItems.spec.tsx b/frontend/packages/console-app/src/components/nodes/node-dashboard/__tests__/VirtualMachinesInventoryItems.spec.tsx new file mode 100644 index 00000000000..e62a90f2305 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/node-dashboard/__tests__/VirtualMachinesInventoryItems.spec.tsx @@ -0,0 +1,173 @@ +import { render, screen } from '@testing-library/react'; +import { useWatchVirtualMachineInstances } from '@console/app/src/components/nodes/NodeVmUtils'; +import { useIsKubevirtPluginActive } from '@console/app/src/utils/kubevirt'; +import type { K8sResourceKind } from '@console/dynamic-plugin-sdk/src'; +import type { NodeKind } from '@console/internal/module/k8s'; +import { NodeDashboardContext } from '../NodeDashboardContext'; +import VirtualMachinesInventoryItems from '../VirtualMachinesInventoryItems'; + +jest.mock('@console/app/src/components/nodes/NodeVmUtils', () => ({ + VirtualMachineModel: { + apiGroup: 'kubevirt.io', + apiVersion: 'v1', + kind: 'VirtualMachine', + plural: 'virtualmachines', + }, + useWatchVirtualMachineInstances: jest.fn(), +})); + +jest.mock('@console/app/src/utils/kubevirt', () => ({ + useIsKubevirtPluginActive: jest.fn(), +})); + +jest.mock('react-router', () => ({ + Link: jest.fn(({ to, children }) => {children}), +})); + +jest.mock('@console/internal/components/utils/resource-link', () => ({ + resourcePathFromModel: jest.fn((model) => `/k8s/all-namespaces/${model.plural}`), +})); + +jest.mock('@console/shared/src/components/dashboard/inventory-card/InventoryItem', () => ({ + InventoryItem: jest.fn(({ title, count, isLoading, error }) => ( +
+ {isLoading ? 'Loading...' : error ? 'Error' : `${title}: ${count}`} +
+ )), +})); + +jest.mock('@console/shared/src/components/description-list/DescriptionListTermHelp', () => ({ + DescriptionListTermHelp: jest.fn(({ text }) =>
{text}
), +})); + +const useIsKubevirtPluginActiveMock = useIsKubevirtPluginActive as jest.Mock; +const useWatchVirtualMachineInstancesMock = useWatchVirtualMachineInstances as jest.Mock; + +describe('VirtualMachinesInventoryItems', () => { + const mockNode: NodeKind = { + apiVersion: 'v1', + kind: 'Node', + metadata: { + name: 'test-node', + uid: 'node-uid', + }, + spec: {}, + status: {}, + }; + + const mockVirtualMachines: K8sResourceKind[] = [ + { + apiVersion: 'kubevirt.io/v1', + kind: 'VirtualMachine', + metadata: { + name: 'vm1', + namespace: 'default', + uid: 'vm-uid-1', + }, + }, + { + apiVersion: 'kubevirt.io/v1', + kind: 'VirtualMachine', + metadata: { + name: 'vm2', + namespace: 'openshift-cnv', + uid: 'vm-uid-2', + }, + }, + ]; + + const renderWithContext = (node: NodeKind = mockNode) => { + return render( + {}, + setMemoryLimit: () => {}, + setHealthCheck: () => {}, + }} + > + + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not render when kubevirt plugin is not active', () => { + useIsKubevirtPluginActiveMock.mockReturnValue(false); + useWatchVirtualMachineInstancesMock.mockReturnValue([[], false, undefined]); + + renderWithContext(); + + expect(screen.queryByTestId('inventory-item')).not.toBeInTheDocument(); + }); + + it('should show loading state when data is loading', () => { + useIsKubevirtPluginActiveMock.mockReturnValue(true); + useWatchVirtualMachineInstancesMock.mockReturnValue([[], false, undefined]); + + renderWithContext(); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('should display error state when there is a load error', () => { + useIsKubevirtPluginActiveMock.mockReturnValue(true); + useWatchVirtualMachineInstancesMock.mockReturnValue([[], true, new Error('Failed to load')]); + + renderWithContext(); + + expect(screen.getByText('Error')).toBeInTheDocument(); + }); + + it('should display VM count when VMs are loaded', () => { + useIsKubevirtPluginActiveMock.mockReturnValue(true); + useWatchVirtualMachineInstancesMock.mockReturnValue([mockVirtualMachines, true, undefined]); + + renderWithContext(); + + expect(screen.getByText('Virtual machine: 2')).toBeInTheDocument(); + }); + + it('should render link to VM search page filtered by node', () => { + useIsKubevirtPluginActiveMock.mockReturnValue(true); + useWatchVirtualMachineInstancesMock.mockReturnValue([mockVirtualMachines, true, undefined]); + + renderWithContext(); + + const link = screen.getByRole('link'); + expect(link).toHaveAttribute( + 'href', + '/k8s/all-namespaces/virtualmachines/search?rowFilter-node=test-node', + ); + }); + + it('should call useWatchVirtualMachineInstances with node name', () => { + useIsKubevirtPluginActiveMock.mockReturnValue(true); + useWatchVirtualMachineInstancesMock.mockReturnValue([[], false, undefined]); + + renderWithContext(mockNode); + + expect(useWatchVirtualMachineInstancesMock).toHaveBeenCalledWith('test-node'); + }); + + it('should display Virtual machines label with help text', () => { + useIsKubevirtPluginActiveMock.mockReturnValue(true); + useWatchVirtualMachineInstancesMock.mockReturnValue([mockVirtualMachines, true, undefined]); + + renderWithContext(); + + expect(screen.getByText('Virtual machines')).toBeInTheDocument(); + }); + + it('should display zero count when no VMs are found', () => { + useIsKubevirtPluginActiveMock.mockReturnValue(true); + useWatchVirtualMachineInstancesMock.mockReturnValue([[], true, undefined]); + + renderWithContext(); + + expect(screen.getByText('Virtual machine: 0')).toBeInTheDocument(); + }); +});