From 992b1ce76291630f2ef0a8efd95ab50c02214bb5 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Date: Fri, 27 Mar 2026 11:13:22 -0400 Subject: [PATCH 1/5] CONSOLE-4946: Add Configuration tab to Node view --- .../console-app/locales/en/console-app.json | 45 +- .../src/components/nodes/NodeDetailsPage.tsx | 17 +- .../src/components/nodes/NodeSubNavPage.tsx | 109 +++++ .../src/components/nodes/NodesPage.tsx | 10 +- .../nodes/configuration/NodeConfiguration.tsx | 30 ++ .../nodes/configuration/NodeMachine.tsx | 324 ++++++++++++++ .../__tests__/NodeConfiguration.spec.tsx | 403 ++++++++++++++++++ .../__tests__/NodeMachine.spec.tsx | 190 +++++++++ .../configuration/node-storage/LocalDisks.tsx | 99 +++++ .../node-storage/NodeStorage.tsx | 28 ++ .../node-storage/PersistentVolumes.tsx | 351 +++++++++++++++ .../__tests__/LocalDisks.spec.tsx | 164 +++++++ .../__tests__/NodeStorage.spec.tsx | 42 ++ .../__tests__/PersistentVolumes.spec.tsx | 325 ++++++++++++++ .../nodes/modals/GroupsEditorModal.tsx | 4 +- .../nodes/modals/NodeGroupsEditorModal.tsx | 2 +- .../__tests__/GroupsEditorModal.spec.tsx | 2 +- .../__tests__/NodeGroupsEditorModal.spec.tsx | 2 +- .../BareMetalInventoryItems.tsx | 2 +- .../nodes/node-dashboard/DetailsCard.tsx | 2 +- .../nodes/node-dashboard/InventoryCard.tsx | 4 +- .../BareMetalInventoryItems.spec.tsx | 8 +- .../nodes/{ => utils}/NodeBareMetalUtils.ts | 15 +- .../nodes/{ => utils}/NodeGroupUtils.ts | 0 .../nodes/{ => utils}/NodeVmUtils.ts | 31 +- .../__tests__/NodeBareMetalUtils.spec.ts | 29 +- .../__tests__/NodeGroupUtils.spec.ts | 2 +- .../{ => utils}/__tests__/NodeVmUtils.spec.ts | 33 +- .../nodes/utils/useAccessibleResources.ts | 87 ++++ .../docs/console-extensions.md | 57 ++- .../src/extensions/index.ts | 1 + .../src/extensions/node-subnav-tabs.ts | 32 ++ .../src/schema/console-extensions.ts | 2 + 33 files changed, 2380 insertions(+), 72 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}/NodeGroupUtils.ts (100%) rename frontend/packages/console-app/src/components/nodes/{ => utils}/NodeVmUtils.ts (79%) rename frontend/packages/console-app/src/components/nodes/{ => utils}/__tests__/NodeBareMetalUtils.spec.ts (89%) rename frontend/packages/console-app/src/components/nodes/{ => utils}/__tests__/NodeGroupUtils.spec.ts (100%) rename frontend/packages/console-app/src/components/nodes/{ => utils}/__tests__/NodeVmUtils.spec.ts (87%) 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 ce594b3c6a8..be575d73c63 100644 --- a/frontend/packages/console-app/locales/en/console-app.json +++ b/frontend/packages/console-app/locales/en/console-app.json @@ -352,6 +352,46 @@ "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", + "Capacity": "Capacity", + "Pod": "Pod", + "No persistent volumes found": "No persistent volumes found", + "Machine": "Machine", + "MachineConfigPool": "MachineConfigPool", + "Max unavailable machines": "Max unavailable machines", + "Paused": "Paused", + "True": "True", + "False": "False", + "Node selector": "Node selector", + "Node": "Node", + "MachineConfig selector": "MachineConfig selector", + "Current configuration": "Current configuration", + "Current configuration source": "Current configuration source", + "Machine details": "Machine details", + "Phase": "Phase", + "Provider state": "Provider state", + "Machine role": "Machine role", + "Instance type": "Instance type", + "Region": "Region", + "Machine addresses": "Machine addresses", + "Error loading machine config pool": "Error loading machine config pool", + "There is no MachineConfigPool associated with this node": "There is no MachineConfigPool associated with this node", + "Error loading machine": "Error loading machine", + "There is no machine associated with this node": "There is no machine associated with this node", "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", @@ -384,7 +424,6 @@ "CPU": "CPU", "Details": "Details", "Node name": "Node name", - "Instance type": "Instance type", "Not available": "Not available", "Node addresses": "Node addresses", "Uptime": "Uptime", @@ -425,7 +464,6 @@ "Annotations": "Annotations", "Annotation_one": "Annotation", "Annotation_other": "Annotations", - "Machine": "Machine", "Provider ID": "Provider ID", "Unschedulable": "Unschedulable", "Created": "Created", @@ -437,11 +475,10 @@ "Container runtime": "Container runtime", "Kubelet version": "Kubelet version", "Kube-Proxy version": "Kube-Proxy version", + "Configuration": "Configuration", "Machine set": "Machine set", "This count is based on your access permissions and might not include all virtual machines.": "This count is based on your access permissions and might not include all virtual machines.", - "MachineConfigPool": "MachineConfigPool", "{{formattedCores}} cores / {{totalCores}} cores": "{{formattedCores}} cores / {{totalCores}} cores", - "Node": "Node", "Ready": "Ready", "Not Ready": "Not Ready", "Discovered": "Discovered", diff --git a/frontend/packages/console-app/src/components/nodes/NodeDetailsPage.tsx b/frontend/packages/console-app/src/components/nodes/NodeDetailsPage.tsx index 0ef0cfbfd74..ed087856fea 100644 --- a/frontend/packages/console-app/src/components/nodes/NodeDetailsPage.tsx +++ b/frontend/packages/console-app/src/components/nodes/NodeDetailsPage.tsx @@ -1,5 +1,6 @@ import type { FC, ComponentProps } from 'react'; import { useCallback } from 'react'; +import { FLAG_NODE_MGMT_V1 } from '@console/app/src/consts'; import { ResourceEventStream } from '@console/internal/components/events'; import { DetailsPage } from '@console/internal/components/factory'; import { PodsPage } from '@console/internal/components/pod-list'; @@ -12,8 +13,10 @@ import { ActionMenuVariant, ActionServiceProvider, } from '@console/shared/src/components/actions'; +import { useFlag } from '@console/shared/src/hooks/useFlag'; 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'; @@ -28,6 +31,8 @@ const NodePodsPage: FC> = ({ obj }) => ( ); export const NodeDetailsPage: FC> = (props) => { + const nodeMgmtV1Enabled = useFlag(FLAG_NODE_MGMT_V1); + const pagesFor = useCallback( (node: NodeKind) => [ { @@ -42,13 +47,23 @@ export const NodeDetailsPage: FC> = (props) = nameKey: 'console-app~Details', component: NodeDetails, }, + ...(nodeMgmtV1Enabled + ? [ + { + href: 'configuration', + // t('console-app~Configuration') + nameKey: 'console-app~Configuration', + component: NodeConfiguration, + }, + ] + : []), navFactory.editYaml(), navFactory.pods(NodePodsPage), navFactory.logs(NodeLogs), navFactory.events(ResourceEventStream), ...(!isWindowsNode(node) ? [navFactory.terminal(NodeTerminal)] : []), ], - [], + [nodeMgmtV1Enabled], ); const customActionMenu = (kindObj: K8sModel, obj: NodeKind) => { 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..cd8c74eedac --- /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 { useTranslatedExtensions } from '@console/plugin-sdk/src/utils/useTranslatedExtensions'; +import { useQueryParams } from '@console/shared/src/hooks/useQueryParams'; +import { useQueryParamsMutator } from '@console/shared/src/hooks/useQueryParamsMutator'; + +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 d822e13716d..437e9a097f3 100644 --- a/frontend/packages/console-app/src/components/nodes/NodesPage.tsx +++ b/frontend/packages/console-app/src/components/nodes/NodesPage.tsx @@ -97,16 +97,16 @@ import { useIsKubevirtPluginActive } from '../../utils/kubevirt'; import { getNodeClientCSRs, isCSRResource } from './csr'; import GroupsEditorModal from './modals/GroupsEditorModal'; import NodeUptime from './node-dashboard/NodeUptime'; -import { getNodeGroups } from './NodeGroupUtils'; import NodeRoles from './NodeRoles'; import { NodeStatusWithExtensions } from './NodeStatus'; -import { - filterVirtualMachineInstancesByNode, - useWatchVirtualMachineInstances, -} from './NodeVmUtils'; import ClientCSRStatus from './status/CSRStatus'; import type { GetNodeStatusExtensions } from './useNodeStatusExtensions'; import { useNodeStatusExtensions } from './useNodeStatusExtensions'; +import { getNodeGroups } from './utils/NodeGroupUtils'; +import { + filterVirtualMachineInstancesByNode, + useWatchVirtualMachineInstances, +} from './utils/NodeVmUtils'; const nodeColumnInfo = Object.freeze({ name: { 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..25ff335c0a9 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/NodeMachine.tsx @@ -0,0 +1,324 @@ +import type { ComponentType, FC } from 'react'; +import { useMemo } from 'react'; +import { + Alert, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Grid, + GridItem, +} from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import NodeIPList from '@console/app/src/components/nodes/NodeIPList'; +import Status from '@console/dynamic-plugin-sdk/src/app/components/status/Status'; +import { getGroupVersionKindForResource } from '@console/dynamic-plugin-sdk/src/utils/k8s'; +import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks'; +import { machineConfigReference } from '@console/internal/components/machine-config'; +import type { PageComponentProps } from '@console/internal/components/utils'; +import { + Selector, + DetailsItem, + ResourceLink, + 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 PaneBody from '@console/shared/src/components/layout/PaneBody'; +import { + getMachineAddresses, + getMachineInstanceType, + getMachinePhase, + getMachineRegion, + getMachineRole, + getMachineZone, +} from '@console/shared/src/selectors/machine'; +import { getNodeMachineNameAndNamespace } from '@console/shared/src/selectors/node'; + +const SkeletonDetails: FC = () => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+); + +type MachineConfigPoolSummaryProps = { + obj: MachineConfigPoolKind; +}; + +const MachineConfigPoolSummary: FC = ({ obj }) => { + const { t } = useTranslation(); + const maxUnavailable = obj?.spec?.maxUnavailable ?? 1; + const machineConfigSelector = obj?.spec?.machineConfigSelector; + + return ( + <> + + + + + + + {t('console-app~Max unavailable machines')} + {maxUnavailable} + + + {obj?.spec?.paused ? t('console-app~True') : t('console-app~False')} + + + + + + {t('console-app~MachineConfig selector')} + + + + + + + ); +}; + +type MachineConfigPoolCharacteristicsProps = { + obj: MachineConfigPoolKind; +}; + +const MachineConfigPoolCharacteristics: FC = ({ obj }) => { + const { t } = useTranslation(); + const configuration = obj?.status?.configuration; + + return ( + + {configuration && ( + <> + + + {t('console-app~Current configuration')} + + {configuration.name ? ( + + ) : ( + '-' + )} + + + + + {t('console-app~Current configuration source')} + + + {configuration.source + ? configuration.source.map((nextSource) => ( + + )) + : '-'} + + + + )} + + ); +}; + +export type MachineDetailsProps = { + obj: MachineKind; +}; + +const MachineDetails: FC = ({ obj }) => { + const { t } = useTranslation(); + const machineRole = getMachineRole(obj); + const instanceType = getMachineInstanceType(obj); + const region = getMachineRegion(obj); + const zone = getMachineZone(obj); + + if (!obj) { + return null; + } + + return ( + <> + + + + + + + + + + + + + + + + + + + + {obj.status?.providerStatus?.instanceState} + + + {t('console-app~Machine role')} + {machineRole} + + {instanceType && ( + + {t('console-app~Instance type')} + {instanceType} + + )} + {region && ( + + {t('console-app~Region')} + {region} + + )} + {zone && ( + + {t('console-app~Availability zone')} + {zone} + + )} + + {t('console-app~Machine addresses')} + + + + + + + + + + ); +}; + +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 ( + <> + {machineConfigPoolsLoadError ? ( +
{t('console-app~Error loading machine config pool')}
+ ) : machineConfigPoolsLoaded ? ( + + {paused && } + + + {machineConfigPool && } + + + {machineConfigPool && } + + + {!machineConfigPool ? ( + + ) : null} + + ) : ( + + )} + {machineLoadError ? ( +
{t('console-app~Error loading machine')}
+ ) : machineLoaded ? ( + machine ? ( + + ) : ( + + +
{t('console-app~There is no machine associated with this node')}
+
+ ) + ) : ( + + )} + + ); +}; + +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..cb78ef181e6 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/__tests__/NodeConfiguration.spec.tsx @@ -0,0 +1,403 @@ +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 { useQueryParams } from '@console/shared/src/hooks/useQueryParams'; +import { useQueryParamsMutator } from '@console/shared/src/hooks/useQueryParamsMutator'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { NodeConfiguration } from '../NodeConfiguration'; + +jest.mock('@console/shared/src/hooks/useQueryParams', () => ({ + 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/shared/src/hooks/useQueryParamsMutator', () => ({ + ...jest.requireActual('@console/shared/src/hooks/useQueryParamsMutator'), + 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 useQueryParamsMutatorMock = useQueryParamsMutator 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 + useQueryParamsMutatorMock.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..75ac01a3bca --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/__tests__/NodeMachine.spec.tsx @@ -0,0 +1,190 @@ +import { 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/selectors/node'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import NodeMachine from '../NodeMachine'; + +jest.mock('@console/dynamic-plugin-sdk/src/utils/k8s/hooks', () => { + const actual = jest.requireActual('@console/dynamic-plugin-sdk/src/utils/k8s/hooks'); + return { + ...actual, + 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', () => ({ + MachineConfigPoolSummary: jest.fn(() =>
MachineConfigPool summary
), +})); + +jest.mock('@console/internal/components/utils', () => { + const actual = jest.requireActual('@console/internal/components/utils'); + return { + ...actual, + PageComponentProps: {}, + SectionHeading: jest.fn(({ text }) =>

{text}

), + WorkloadPausedAlert: jest.fn(() =>
Workload paused alert
), + }; +}); + +jest.mock('@console/shared/src/selectors/node', () => ({ + 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: 'test-mcp', + uid: 'mcp-uid', + }, + spec: { + nodeSelector: { + matchLabels: { + 'node-role.kubernetes.io/worker': '', + }, + }, + paused: false, + }, + status: { + configuration: {}, + }, + }; + + 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 } = renderWithProviders(); + + 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]); + + renderWithProviders(); + + expect(screen.getByText('Error loading machine')).toBeInTheDocument(); + }); + + it('should display message when machine is not found', () => { + useK8sWatchResourceMock + .mockReturnValueOnce([null, true, undefined]) + .mockReturnValueOnce([[], true, undefined]); + + renderWithProviders(); + + expect(screen.getByText('There is no machine associated with this node')).toBeInTheDocument(); + }); + + it('should display machine details when machine is loaded', () => { + useK8sWatchResourceMock + .mockReturnValueOnce([mockMachine, true, undefined]) + .mockReturnValueOnce([[mockMachineConfigPool], true, undefined]); + + renderWithProviders(); + + expect(screen.getByText('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')]); + + renderWithProviders(); + + 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]); + + renderWithProviders(); + + expect( + screen.getByText('There is no MachineConfigPool associated with this node'), + ).toBeInTheDocument(); + }); + + it('should display machine config pool details when loaded', () => { + useK8sWatchResourceMock + .mockReturnValueOnce([mockMachine, true, undefined]) + .mockReturnValueOnce([[mockMachineConfigPool], true, undefined]); + + renderWithProviders(); + + expect(screen.getByText('test-mcp')).toBeInTheDocument(); + expect(screen.getByText('MachineConfigs')).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]); + + renderWithProviders(); + + 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]); + + renderWithProviders(); + + 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..bf576e5f0b8 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/node-storage/LocalDisks.tsx @@ -0,0 +1,99 @@ +import type { FC } from 'react'; +import { Title } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import { DASH } from '@console/dynamic-plugin-sdk/src/app/constants'; +import { humanizeDecimalBytes } from '@console/internal/components/utils/units'; +import type { NodeKind } from '@console/internal/module/k8s'; +import { useDeepCompareMemoize } from '@console/shared/src/hooks/useDeepCompareMemoize'; +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..9ece47908d4 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/node-storage/PersistentVolumes.tsx @@ -0,0 +1,351 @@ +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 { DASH } from '@console/dynamic-plugin-sdk/src/app/constants'; +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 { getUID } from '@console/shared/src/selectors/common'; +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.persistentVolume.spec?.storageClassName ? ( + + ) : ( + t('console-app~None') + )} + + + {persistentVolumeData.persistentVolume.spec?.capacity?.storage ?? DASH} + + + {persistentVolumeData.persistentVolumeClaim?.metadata.namespace ?? DASH} + + + {pod ? ( + + ) : ( + 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~StorageClass')}{t('console-app~Capacity')}{t('console-app~Namespace')}{t('console-app~Pod')}
+ {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..83313f395f5 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/node-storage/__tests__/PersistentVolumes.spec.tsx @@ -0,0 +1,325 @@ +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/dynamic-plugin-sdk/src/utils/flags', () => ({ + ...jest.requireActual('@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/modals/GroupsEditorModal.tsx b/frontend/packages/console-app/src/components/nodes/modals/GroupsEditorModal.tsx index 9f56f8cb4e2..69982741b0a 100644 --- a/frontend/packages/console-app/src/components/nodes/modals/GroupsEditorModal.tsx +++ b/frontend/packages/console-app/src/components/nodes/modals/GroupsEditorModal.tsx @@ -37,7 +37,7 @@ import { ExpandableAlert } from '@console/internal/components/utils'; import { NodeModel } from '@console/internal/models'; import { useDeepCompareMemoize } from '@console/shared/src/hooks/useDeepCompareMemoize'; import type { ModalComponentProps } from '@console/shared/src/types/modal'; -import type { GroupNameMap } from '../NodeGroupUtils'; +import type { GroupNameMap } from '../utils/NodeGroupUtils'; import { GROUP_SEPARATOR, getGroupsByNameFromNodes, @@ -46,7 +46,7 @@ import { getNodeGroupAnnotationFromGroups, getNodeGroups, GROUP_ANNOTATION, -} from '../NodeGroupUtils'; +} from '../utils/NodeGroupUtils'; import './node-group-editor-modal.scss'; diff --git a/frontend/packages/console-app/src/components/nodes/modals/NodeGroupsEditorModal.tsx b/frontend/packages/console-app/src/components/nodes/modals/NodeGroupsEditorModal.tsx index 9c1e89b3428..fa7bdbe95e3 100644 --- a/frontend/packages/console-app/src/components/nodes/modals/NodeGroupsEditorModal.tsx +++ b/frontend/packages/console-app/src/components/nodes/modals/NodeGroupsEditorModal.tsx @@ -41,7 +41,7 @@ import { getNodeGroups, GROUP_ANNOTATION, GROUP_SEPARATOR, -} from '../NodeGroupUtils'; +} from '../utils/NodeGroupUtils'; import './node-group-editor-modal.scss'; diff --git a/frontend/packages/console-app/src/components/nodes/modals/__tests__/GroupsEditorModal.spec.tsx b/frontend/packages/console-app/src/components/nodes/modals/__tests__/GroupsEditorModal.spec.tsx index 4bdd79e44b6..e80484c0268 100644 --- a/frontend/packages/console-app/src/components/nodes/modals/__tests__/GroupsEditorModal.spec.tsx +++ b/frontend/packages/console-app/src/components/nodes/modals/__tests__/GroupsEditorModal.spec.tsx @@ -5,7 +5,7 @@ import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/h import { NodeModel } from '@console/internal/models'; import type { NodeKind } from '@console/internal/module/k8s'; import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; -import { GROUP_ANNOTATION } from '../../NodeGroupUtils'; +import { GROUP_ANNOTATION } from '../../utils/NodeGroupUtils'; import GroupsEditorModal from '../GroupsEditorModal'; jest.mock('@console/dynamic-plugin-sdk/src/utils/k8s/hooks', () => ({ diff --git a/frontend/packages/console-app/src/components/nodes/modals/__tests__/NodeGroupsEditorModal.spec.tsx b/frontend/packages/console-app/src/components/nodes/modals/__tests__/NodeGroupsEditorModal.spec.tsx index 382d637704d..5945ce79717 100644 --- a/frontend/packages/console-app/src/components/nodes/modals/__tests__/NodeGroupsEditorModal.spec.tsx +++ b/frontend/packages/console-app/src/components/nodes/modals/__tests__/NodeGroupsEditorModal.spec.tsx @@ -5,7 +5,7 @@ import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/h import { NodeModel } from '@console/internal/models'; import type { NodeKind } from '@console/internal/module/k8s'; import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; -import { GROUP_ANNOTATION } from '../../NodeGroupUtils'; +import { GROUP_ANNOTATION } from '../../utils/NodeGroupUtils'; import NodeGroupsEditorModal from '../NodeGroupsEditorModal'; jest.mock('@console/dynamic-plugin-sdk/src/utils/k8s/hooks', () => ({ 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 271964d9cd5..d97cb3ffd6e 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/DetailsCard.tsx b/frontend/packages/console-app/src/components/nodes/node-dashboard/DetailsCard.tsx index e36d88d58da..460caa8e273 100644 --- a/frontend/packages/console-app/src/components/nodes/node-dashboard/DetailsCard.tsx +++ b/frontend/packages/console-app/src/components/nodes/node-dashboard/DetailsCard.tsx @@ -17,7 +17,7 @@ import { } from '@patternfly/react-core'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router'; -import { getNodeGroups } from '@console/app/src/components/nodes/NodeGroupUtils'; +import { getNodeGroups } from '@console/app/src/components/nodes/utils/NodeGroupUtils'; import { FLAG_NODE_MGMT_V1 } from '@console/app/src/consts'; import { useAccessReview } from '@console/dynamic-plugin-sdk/src/api/dynamic-core-api'; import { useOverlay } from '@console/dynamic-plugin-sdk/src/app/modal-support/useOverlay'; 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..e0e5da8b7a3 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 @@ -13,6 +13,7 @@ import { import { useTranslation } from 'react-i18next'; import { Link } from 'react-router'; import BareMetalInventoryItems from '@console/app/src/components/nodes/node-dashboard/BareMetalInventoryItems'; +import { useIsKubevirtPluginActive } from '@console/app/src/utils/kubevirt'; 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'; @@ -25,8 +26,7 @@ import { } 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 { useWatchVirtualMachineInstances, VirtualMachineModel } 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 7ccef721f4a..9d6e47dd8e4 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/NodeGroupUtils.ts b/frontend/packages/console-app/src/components/nodes/utils/NodeGroupUtils.ts similarity index 100% rename from frontend/packages/console-app/src/components/nodes/NodeGroupUtils.ts rename to frontend/packages/console-app/src/components/nodes/utils/NodeGroupUtils.ts 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 79% rename from frontend/packages/console-app/src/components/nodes/NodeVmUtils.ts rename to frontend/packages/console-app/src/components/nodes/utils/NodeVmUtils.ts index 5ef9d2fe1fd..1d861de0e3c 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,5 @@ +import { useMemo } from 'react'; +import { useIsKubevirtPluginActive } from '@console/app/src/utils/kubevirt'; import type { K8sGroupVersionKind, K8sModel, @@ -5,9 +7,8 @@ import type { K8sResourceKind, WatchK8sResult, } from '@console/dynamic-plugin-sdk/src'; -import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks'; import type { PodKind } from '@console/internal/module/k8s'; -import { useIsKubevirtPluginActive } from '../../utils/kubevirt'; +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', @@ -38,22 +52,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__/NodeGroupUtils.spec.ts b/frontend/packages/console-app/src/components/nodes/utils/__tests__/NodeGroupUtils.spec.ts similarity index 100% rename from frontend/packages/console-app/src/components/nodes/__tests__/NodeGroupUtils.spec.ts rename to frontend/packages/console-app/src/components/nodes/utils/__tests__/NodeGroupUtils.spec.ts index 8dec948c807..4e6724f48c3 100644 --- a/frontend/packages/console-app/src/components/nodes/__tests__/NodeGroupUtils.spec.ts +++ b/frontend/packages/console-app/src/components/nodes/utils/__tests__/NodeGroupUtils.spec.ts @@ -1,5 +1,4 @@ import type { NodeKind } from '@console/dynamic-plugin-sdk/src'; -import type { GroupNameMap } from '../NodeGroupUtils'; import { GROUP_ANNOTATION, getGroupsFromGroupAnnotation, @@ -9,6 +8,7 @@ import { getGroupsByNameFromNodes, getNodeGroupAnnotationFromGroupNameMap, } from '../NodeGroupUtils'; +import type { GroupNameMap } from '../NodeGroupUtils'; describe('NodeGroupUtils', () => { describe('getGroupsFromGroupAnnotation', () => { 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 87% 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 1822c10c13f..8d344104f19 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,8 +1,13 @@ -import { renderHook } from '@testing-library/react'; +import { useIsKubevirtPluginActive } from '@console/app/src/utils/kubevirt'; import type { K8sResourceCommon, K8sResourceKind } from '@console/dynamic-plugin-sdk/src'; -import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks'; +import { useFlag } from '@console/dynamic-plugin-sdk/src/utils/flags'; +import { + useK8sWatchResource, + useK8sWatchResources, +} from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks'; import type { PodKind } from '@console/internal/module/k8s'; -import { useIsKubevirtPluginActive } from '../../../utils/kubevirt'; +import { FLAGS } from '@console/shared/src/constants/common'; +import { renderHookWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; import { filterVirtualMachineInstancesByNode, getCurrentPod, @@ -12,16 +17,23 @@ import { VirtualMachineInstanceGroupVersionKind, } from '../NodeVmUtils'; -jest.mock('../../../utils/kubevirt', () => ({ +jest.mock('@console/app/src/utils/kubevirt', () => ({ useIsKubevirtPluginActive: jest.fn(), })); +jest.mock('@console/dynamic-plugin-sdk/src/utils/flags', () => ({ + useFlag: jest.fn(), +})); + jest.mock('@console/dynamic-plugin-sdk/src/utils/k8s/hooks', () => ({ useK8sWatchResource: jest.fn(), + useK8sWatchResources: jest.fn(), })); const useIsKubevirtPluginActiveMock = useIsKubevirtPluginActive as jest.Mock; +const useFlagMock = useFlag as jest.Mock; const useK8sWatchResourceMock = useK8sWatchResource as jest.Mock; +const useK8sWatchResourcesMock = useK8sWatchResources as jest.Mock; describe('NodeVmUtils', () => { beforeEach(() => { @@ -246,11 +258,16 @@ describe('NodeVmUtils', () => { }); describe('useWatchVirtualMachineInstances', () => { + beforeEach(() => { + useFlagMock.mockImplementation((flag) => flag === FLAGS.CAN_LIST_NS); + }); + it('should not watch resources when kubevirt plugin is not active', () => { useIsKubevirtPluginActiveMock.mockReturnValue(false); useK8sWatchResourceMock.mockReturnValue([[], false, undefined]); + useK8sWatchResourcesMock.mockReturnValue({}); - renderHook(() => useWatchVirtualMachineInstances('node-1')); + renderHookWithProviders(() => useWatchVirtualMachineInstances('node-1')); expect(useK8sWatchResourceMock).toHaveBeenCalledWith(undefined); }); @@ -258,12 +275,14 @@ describe('NodeVmUtils', () => { it('should watch VMIs when kubevirt plugin is active', () => { useIsKubevirtPluginActiveMock.mockReturnValue(true); useK8sWatchResourceMock.mockReturnValue([[], false, undefined]); + useK8sWatchResourcesMock.mockReturnValue({}); - renderHook(() => useWatchVirtualMachineInstances('node-1')); + renderHookWithProviders(() => useWatchVirtualMachineInstances('node-1')); expect(useK8sWatchResourceMock).toHaveBeenCalledWith({ isList: true, groupVersionKind: VirtualMachineInstanceGroupVersionKind, + namespaced: true, }); }); @@ -283,7 +302,7 @@ describe('NodeVmUtils', () => { useK8sWatchResourceMock.mockReturnValue([vmis, true, undefined]); - const { result } = renderHook(() => useWatchVirtualMachineInstances('node-1')); + const { result } = renderHookWithProviders(() => useWatchVirtualMachineInstances('node-1')); expect(result.current[0]).toHaveLength(1); expect(result.current[0][0].metadata.name).toBe('vmi-1'); 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 72a9bb7a9aa..005660024f7 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/docs/console-extensions.md +++ b/frontend/packages/console-dynamic-plugin-sdk/docs/console-extensions.md @@ -58,26 +58,27 @@ 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) +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) --- @@ -1060,6 +1061,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 | +| `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..bb8c61c25be --- /dev/null +++ b/frontend/packages/console-dynamic-plugin-sdk/src/extensions/node-subnav-tabs.ts @@ -0,0 +1,32 @@ +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 + */ + 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 00ef706ee19..fca5fb0e65b 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 @@ -42,6 +42,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, @@ -128,6 +129,7 @@ export type SupportedExtension = | UserPreferenceItem | Perspective | HorizontalNavTab + | NodeSubNavTab | NavTab | ClusterOverviewInventoryItem | ClusterOverviewUtilizationItem From 3022012ab1f98b51ec836c0686cadd4c30a75794 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Date: Mon, 6 Apr 2026 17:55:23 -0400 Subject: [PATCH 2/5] Updates per UX review --- .../console-app/locales/en/console-app.json | 51 ++- .../src/components/nodes/NodeSubNavPage.tsx | 2 +- .../nodes/configuration/NodeConfiguration.tsx | 14 +- .../nodes/configuration/NodeMachine.tsx | 324 ------------------ .../nodes/configuration/OperatingSystem.tsx | 209 +++++++++++ .../__tests__/NodeConfiguration.spec.tsx | 17 +- .../machine/BMCConfiguration.tsx | 221 ++++++++++++ .../nodes/configuration/machine/Machine.tsx | 14 + .../configuration/machine/MachineDetails.tsx | 154 +++++++++ .../__tests__/BMCConfiguration.spec.tsx | 161 +++++++++ .../__tests__/NodeMachine.spec.tsx | 111 +++--- .../configuration/node-storage/LocalDisks.tsx | 6 +- .../node-storage/PersistentVolumes.tsx | 45 +-- .../nodes/utils/NodeBareMetalUtils.ts | 29 +- .../src/components/nodes/utils/NodeVmUtils.ts | 11 +- 15 files changed, 930 insertions(+), 439 deletions(-) delete mode 100644 frontend/packages/console-app/src/components/nodes/configuration/NodeMachine.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/configuration/OperatingSystem.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/configuration/machine/BMCConfiguration.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/configuration/machine/Machine.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/configuration/machine/MachineDetails.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/configuration/machine/__tests__/BMCConfiguration.spec.tsx rename frontend/packages/console-app/src/components/nodes/configuration/{ => machine}/__tests__/NodeMachine.spec.tsx (70%) diff --git a/frontend/packages/console-app/locales/en/console-app.json b/frontend/packages/console-app/locales/en/console-app.json index be575d73c63..e9a6feeaa79 100644 --- a/frontend/packages/console-app/locales/en/console-app.json +++ b/frontend/packages/console-app/locales/en/console-app.json @@ -352,10 +352,41 @@ "Unpin": "Unpin", "Remove from navigation?": "Remove from navigation?", "Remove": "Remove", + "Host addresses": "Host addresses", + "Management": "Management", + "NICs": "NICs", + "Boot interface MAC": "Boot interface MAC", + "Detached": "Detached", + "No power management": "No power management", + "Restart pending": "Restart pending", + "On": "On", + "Powering off": "Powering off", + "Powering on": "Powering on", + "Off": "Off", + "Details": "Details", + "Address": "Address", + "Firmware": "Firmware", + "Power state": "Power state", + "Credentials": "Credentials", + "namespace {{namespace}}": "namespace {{namespace}}", + "BMC Configuration": "BMC Configuration", + "Unable to load BMC configuration": "Unable to load BMC configuration", + "There is no BMC configuration associated with this node": "There is no BMC configuration associated with this node", + "Machine details": "Machine details", + "Machine is not available": "Machine is not available", + "Unable to load Machine resources": "Unable to load Machine resources", + "There is no machine associated with this node": "There is no machine associated with this node", + "Phase": "Phase", + "Provider state": "Provider state", + "Machine role": "Machine role", + "Instance type": "Instance type", + "Region": "Region", + "Machine addresses": "Machine addresses", "Rotational": "Rotational", "SSD": "SSD", "Local disks": "Local disks", "Unable to load local disks": "Unable to load local disks", + "Unable to load BareMetalHost": "Unable to load BareMetalHost", "Model": "Model", "Serial number": "Serial number", "Vendor": "Vendor", @@ -370,28 +401,20 @@ "Capacity": "Capacity", "Pod": "Pod", "No persistent volumes found": "No persistent volumes found", + "Operating system": "Operating system", "Machine": "Machine", "MachineConfigPool": "MachineConfigPool", + "MachineConfigPools are not available": "MachineConfigPools are not available", + "Unable to load MachineConfigPool resources": "Unable to load MachineConfigPool resources", + "There is no MachineConfigPool associated with this node": "There is no MachineConfigPool associated with this node", "Max unavailable machines": "Max unavailable machines", "Paused": "Paused", "True": "True", "False": "False", "Node selector": "Node selector", - "Node": "Node", "MachineConfig selector": "MachineConfig selector", "Current configuration": "Current configuration", "Current configuration source": "Current configuration source", - "Machine details": "Machine details", - "Phase": "Phase", - "Provider state": "Provider state", - "Machine role": "Machine role", - "Instance type": "Instance type", - "Region": "Region", - "Machine addresses": "Machine addresses", - "Error loading machine config pool": "Error loading machine config pool", - "There is no MachineConfigPool associated with this node": "There is no MachineConfigPool associated with this node", - "Error loading machine": "Error loading machine", - "There is no machine associated with this node": "There is no machine associated with this node", "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", @@ -422,7 +445,6 @@ "Disk": "Disk", "Network interface": "Network interface", "CPU": "CPU", - "Details": "Details", "Node name": "Node name", "Not available": "Not available", "Node addresses": "Node addresses", @@ -467,7 +489,6 @@ "Provider ID": "Provider ID", "Unschedulable": "Unschedulable", "Created": "Created", - "Operating system": "Operating system", "OS image": "OS image", "Architecture": "Architecture", "Kernel version": "Kernel version", @@ -479,6 +500,7 @@ "Machine set": "Machine set", "This count is based on your access permissions and might not include all virtual machines.": "This count is based on your access permissions and might not include all virtual machines.", "{{formattedCores}} cores / {{totalCores}} cores": "{{formattedCores}} cores / {{totalCores}} cores", + "Node": "Node", "Ready": "Ready", "Not Ready": "Not Ready", "Discovered": "Discovered", @@ -502,6 +524,7 @@ "Certificate approval required": "Certificate approval required", "An error occurred. Please try again": "An error occurred. Please try again", "No new Pods or workloads will be placed on this Node until it's marked as schedulable.": "No new Pods or workloads will be placed on this Node until it's marked as schedulable.", + "Unable to load VirtualMachines": "Unable to load VirtualMachines", "Identity providers": "Identity providers", "Mapping method": "Mapping method", "Remove identity provider": "Remove identity provider", diff --git a/frontend/packages/console-app/src/components/nodes/NodeSubNavPage.tsx b/frontend/packages/console-app/src/components/nodes/NodeSubNavPage.tsx index cd8c74eedac..42281c36b7e 100644 --- a/frontend/packages/console-app/src/components/nodes/NodeSubNavPage.tsx +++ b/frontend/packages/console-app/src/components/nodes/NodeSubNavPage.tsx @@ -75,7 +75,7 @@ export const NodeSubNavPage: FC = ({ obj, pageId, standardP ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-); - -type MachineConfigPoolSummaryProps = { - obj: MachineConfigPoolKind; -}; - -const MachineConfigPoolSummary: FC = ({ obj }) => { - const { t } = useTranslation(); - const maxUnavailable = obj?.spec?.maxUnavailable ?? 1; - const machineConfigSelector = obj?.spec?.machineConfigSelector; - - return ( - <> - - - - - - - {t('console-app~Max unavailable machines')} - {maxUnavailable} - - - {obj?.spec?.paused ? t('console-app~True') : t('console-app~False')} - - - - - - {t('console-app~MachineConfig selector')} - - - - - - - ); -}; - -type MachineConfigPoolCharacteristicsProps = { - obj: MachineConfigPoolKind; -}; - -const MachineConfigPoolCharacteristics: FC = ({ obj }) => { - const { t } = useTranslation(); - const configuration = obj?.status?.configuration; - - return ( - - {configuration && ( - <> - - - {t('console-app~Current configuration')} - - {configuration.name ? ( - - ) : ( - '-' - )} - - - - - {t('console-app~Current configuration source')} - - - {configuration.source - ? configuration.source.map((nextSource) => ( - - )) - : '-'} - - - - )} - - ); -}; - -export type MachineDetailsProps = { - obj: MachineKind; -}; - -const MachineDetails: FC = ({ obj }) => { - const { t } = useTranslation(); - const machineRole = getMachineRole(obj); - const instanceType = getMachineInstanceType(obj); - const region = getMachineRegion(obj); - const zone = getMachineZone(obj); - - if (!obj) { - return null; - } - - return ( - <> - - - - - - - - - - - - - - - - - - - - {obj.status?.providerStatus?.instanceState} - - - {t('console-app~Machine role')} - {machineRole} - - {instanceType && ( - - {t('console-app~Instance type')} - {instanceType} - - )} - {region && ( - - {t('console-app~Region')} - {region} - - )} - {zone && ( - - {t('console-app~Availability zone')} - {zone} - - )} - - {t('console-app~Machine addresses')} - - - - - - - - - - ); -}; - -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 ( - <> - {machineConfigPoolsLoadError ? ( -
{t('console-app~Error loading machine config pool')}
- ) : machineConfigPoolsLoaded ? ( - - {paused && } - - - {machineConfigPool && } - - - {machineConfigPool && } - - - {!machineConfigPool ? ( - - ) : null} - - ) : ( - - )} - {machineLoadError ? ( -
{t('console-app~Error loading machine')}
- ) : machineLoaded ? ( - machine ? ( - - ) : ( - - -
{t('console-app~There is no machine associated with this node')}
-
- ) - ) : ( - - )} - - ); -}; - -export default NodeMachine; diff --git a/frontend/packages/console-app/src/components/nodes/configuration/OperatingSystem.tsx b/frontend/packages/console-app/src/components/nodes/configuration/OperatingSystem.tsx new file mode 100644 index 00000000000..2c8ea387697 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/OperatingSystem.tsx @@ -0,0 +1,209 @@ +import type { ComponentType, FC } from 'react'; +import { useMemo } from 'react'; +import { + Alert, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Grid, + GridItem, +} from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import { getGroupVersionKindForResource } from '@console/dynamic-plugin-sdk/src/utils/k8s'; +import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks'; +import { machineConfigReference } from '@console/internal/components/machine-config'; +import type { PageComponentProps } from '@console/internal/components/utils'; +import { + Selector, + DetailsItem, + ResourceLink, + SectionHeading, + WorkloadPausedAlert, +} from '@console/internal/components/utils'; +import { MachineConfigPoolModel, NodeModel } from '@console/internal/models'; +import type { MachineConfigPoolKind, NodeKind } from '@console/internal/module/k8s'; +import { referenceForModel } from '@console/internal/module/k8s'; +import { LabelSelector } from '@console/internal/module/k8s/label-selector'; +import PaneBody from '@console/shared/src/components/layout/PaneBody'; + +const SkeletonDetails: FC = () => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+); + +type MachineConfigPoolSummaryProps = { + obj?: MachineConfigPoolKind; + loadError?: any; +}; + +const MachineConfigPoolSummary: FC = ({ obj, loadError }) => { + const { t } = useTranslation(); + const maxUnavailable = obj?.spec?.maxUnavailable ?? 1; + const machineConfigSelector = obj?.spec?.machineConfigSelector; + + return ( + <> + + {loadError ? ( + + {loadError.message || t('console-app~Unable to load MachineConfigPool resources')} + + ) : !obj ? ( + + ) : ( + + + + + + {t('console-app~Max unavailable machines')} + {maxUnavailable} + + + {obj?.spec?.paused ? t('console-app~True') : t('console-app~False')} + + + + + + {t('console-app~MachineConfig selector')} + + + + + + )} + + ); +}; + +type MachineConfigPoolCharacteristicsProps = { + obj: MachineConfigPoolKind; +}; + +const MachineConfigPoolCharacteristics: FC = ({ obj }) => { + const { t } = useTranslation(); + const configuration = obj?.status?.configuration; + + return ( + + {configuration && ( + <> + + + {t('console-app~Current configuration')} + + {configuration.name ? ( + + ) : ( + '-' + )} + + + + + {t('console-app~Current configuration source')} + + + {configuration.source + ? configuration.source.map((nextSource) => ( + + )) + : '-'} + + + + )} + + ); +}; + +const NodeMachine: ComponentType> = ({ obj }) => { + 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 ( + <> + {machineConfigPoolsLoaded ? ( + + {paused && } + + + + + + {machineConfigPool && } + + + + ) : ( + + )} + + ); +}; + +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 index cb78ef181e6..f781665283d 100644 --- 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 @@ -17,7 +17,12 @@ jest.mock('../node-storage/NodeStorage', () => ({ default: jest.fn(() => null), })); -jest.mock('../NodeMachine', () => ({ +jest.mock('../machine/MachineDetails', () => ({ + __esModule: true, + default: jest.fn(() => null), +})); + +jest.mock('../machine/Machine', () => ({ __esModule: true, default: jest.fn(() => null), })); @@ -87,7 +92,7 @@ describe('NodeConfiguration', () => { const mockQueryParams = new URLSearchParams('activeTab=machine'); useQueryParamsMock.mockReturnValue(mockQueryParams); - render(); + renderWithProviders(); const tabs = screen.getAllByRole('tab'); const machineTab = tabs.find((tab) => tab.textContent === 'Machine'); @@ -222,7 +227,13 @@ describe('NodeConfiguration', () => { 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']); + expect(tabNames).toEqual([ + 'High Priority', + 'Storage', + 'Operating system', + 'Machine', + 'Low Priority', + ]); }); it('should render component from plugin extension when tab is active', async () => { diff --git a/frontend/packages/console-app/src/components/nodes/configuration/machine/BMCConfiguration.tsx b/frontend/packages/console-app/src/components/nodes/configuration/machine/BMCConfiguration.tsx new file mode 100644 index 00000000000..3382c51bc73 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/machine/BMCConfiguration.tsx @@ -0,0 +1,221 @@ +import type { FC } from 'react'; +import { + Alert, + Content, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Flex, + FlexItem, +} from '@patternfly/react-core'; +import type { TFunction } from 'i18next'; +import { useTranslation } from 'react-i18next'; +import { SectionHeading } from '@console/internal/components/utils'; +import type { K8sResourceKind, NodeKind } from '@console/internal/module/k8s'; +import { DASH, DetailPropertyList, DetailPropertyListItem } from '@console/shared/src'; +import PaneBody from '@console/shared/src/components/layout/PaneBody'; +import { + HOST_STATUS_UNMANAGED, + HOST_STATUS_DETACHED, + useIsBareMetalPluginActive, + useWatchBareMetalHost, + getPoweroffAnnotation, + isHostOnline, + isHostPoweredOn, + isHostScheduledForRestart, +} from '../../utils/NodeBareMetalUtils'; + +const SkeletonDetails: FC = () => ( +
+
+
+
+
+
+
+
+
+); + +type ConfigPropertyListItemProps = { + title: string; + isLoading: boolean; + children?: React.ReactNode; +}; + +const ConfigPropertyListItem: FC = ({ title, children, isLoading }) => + isLoading ? ( +
+ ) : ( + {children} + ); + +type HostAddressesProps = { + bareMetalHost: K8sResourceKind; + isLoading: boolean; +}; + +const HostAddresses: FC = ({ bareMetalHost, isLoading }) => { + const { t } = useTranslation(); + + return ( + + + {t('console-app~Host addresses')} + + + + {bareMetalHost?.spec?.bmc?.address ?? DASH} + + + { + // Intentionally include blank nics + bareMetalHost?.status?.hardware?.nics?.map((nic) => nic.ip).join(', ') || DASH + } + + + {bareMetalHost?.spec?.bootMACAddress ?? DASH} + + + + + + ); +}; + +const hostPowerStatus = (host: K8sResourceKind, t: TFunction) => { + if (host.status?.operationalStatus === HOST_STATUS_DETACHED) { + return t('console-app~Detached'); + } + + if (host.status?.provisioning?.state === HOST_STATUS_UNMANAGED) { + return t('console-app~No power management'); + } + + if (isHostScheduledForRestart(host)) { + return t('console-app~Restart pending'); + } + + const isOnline = isHostOnline(host); + const isPoweredOn = isHostPoweredOn(host); + const poweroffAnnotation = getPoweroffAnnotation(host); + if (isOnline && isPoweredOn && !poweroffAnnotation) return t('console-app~On'); + if ((!isOnline || poweroffAnnotation) && isPoweredOn) return t('console-app~Powering off'); + if (isOnline && !isPoweredOn && !poweroffAnnotation) return t('console-app~Powering on'); + return t('console-app~Off'); +}; + +type BMCDetailsProps = { + bareMetalHost: K8sResourceKind; + isLoading: boolean; +}; + +const BMCConfigDetails: FC = ({ bareMetalHost, isLoading }) => { + const { t } = useTranslation(); + + const bmcAddress = bareMetalHost?.spec?.bmc?.address; + const protocolRaw = bmcAddress?.split('://')?.[0]?.toLowerCase(); + const protocol = protocolRaw?.includes('redfish') + ? 'Redfish' + : protocolRaw?.includes('idrac') + ? 'iDRAC' + : protocolRaw + ? protocolRaw.toUpperCase() + : undefined; + const manufacturer = bareMetalHost?.status?.hardware?.systemVendor?.manufacturer; + const productName = bareMetalHost?.status?.hardware?.systemVendor?.productName; + const hardwareType = [manufacturer, productName].filter(Boolean).join(' '); + const firmwareVersion = + bareMetalHost?.status?.hardware?.firmware?.bmcVersion ?? + bareMetalHost?.status?.hardware?.firmware?.bios?.version; + const bmcType = [hardwareType, protocol ? `(${protocol})` : undefined].filter(Boolean).join(' '); + + return ( + + + {t('console-app~Details')} + + + + {bmcType || DASH} + + + {bmcAddress ?? DASH} + + + {firmwareVersion ?? DASH} + + + {bareMetalHost ? hostPowerStatus(bareMetalHost, t) : DASH} + + + {bareMetalHost?.status?.goodCredentials?.credentials?.name + ? `${bareMetalHost.status.goodCredentials.credentials.name} ${ + bareMetalHost.status.goodCredentials.credentials.namespace + ? `(${t('console-app~namespace {{namespace}}', { + namespace: bareMetalHost.status.goodCredentials.credentials.namespace, + })})` + : '' + }` + : DASH} + + + + + + ); +}; + +type BMCConfigurationProps = { + node: NodeKind; +}; + +const BMCConfiguration: FC = ({ node }) => { + const { t } = useTranslation(); + + const showBareMetal = useIsBareMetalPluginActive(); + + const [bareMetalHost, bareMetalHostLoaded, bareMetalHostLoadError] = useWatchBareMetalHost(node); + + if (!showBareMetal) { + return null; + } + + return ( + + + {bareMetalHostLoadError ? ( + +

{bareMetalHostLoadError.message ?? null}

+
+ ) : !bareMetalHostLoaded ? ( + + ) : !bareMetalHost ? ( + + {t('console-app~There is no BMC configuration associated with this node')} + + ) : ( + + + + + + + + + )} +
+ ); +}; + +export default BMCConfiguration; diff --git a/frontend/packages/console-app/src/components/nodes/configuration/machine/Machine.tsx b/frontend/packages/console-app/src/components/nodes/configuration/machine/Machine.tsx new file mode 100644 index 00000000000..97e04f633aa --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/machine/Machine.tsx @@ -0,0 +1,14 @@ +import type { ComponentType } from 'react'; +import type { PageComponentProps } from '@console/internal/components/utils'; +import type { NodeKind } from '@console/internal/module/k8s'; +import BMCConfiguration from './BMCConfiguration'; +import MachineDetails from './MachineDetails'; + +const NodeMachine: ComponentType> = ({ obj }) => ( + <> + + + +); + +export default NodeMachine; diff --git a/frontend/packages/console-app/src/components/nodes/configuration/machine/MachineDetails.tsx b/frontend/packages/console-app/src/components/nodes/configuration/machine/MachineDetails.tsx new file mode 100644 index 00000000000..c54e79cc031 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/machine/MachineDetails.tsx @@ -0,0 +1,154 @@ +import type { FC } from 'react'; +import { + Alert, + Content, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Grid, + GridItem, +} from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import NodeIPList from '@console/app/src/components/nodes/NodeIPList'; +import Status from '@console/dynamic-plugin-sdk/src/app/components/status/Status'; +import { getGroupVersionKindForResource } from '@console/dynamic-plugin-sdk/src/utils/k8s'; +import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks'; +import { DetailsItem, ResourceLink, SectionHeading } from '@console/internal/components/utils'; +import { MachineModel } from '@console/internal/models'; +import type { MachineKind, NodeKind } from '@console/internal/module/k8s'; +import PaneBody from '@console/shared/src/components/layout/PaneBody'; +import { + getMachineAddresses, + getMachineInstanceType, + getMachinePhase, + getMachineRegion, + getMachineRole, + getMachineZone, +} from '@console/shared/src/selectors/machine'; +import { getNodeMachineNameAndNamespace } from '@console/shared/src/selectors/node'; + +const SkeletonDetails: FC = () => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+); + +type MachineDetailProps = { + node: NodeKind; +}; + +const MachineDetails: FC = ({ node }) => { + const { t } = useTranslation(); + const [machineName, machineNamespace] = getNodeMachineNameAndNamespace(node); + const [machine, machineLoaded, machineLoadError] = useK8sWatchResource( + machineName && machineNamespace + ? { + groupVersionKind: { + kind: MachineModel.kind, + group: MachineModel.apiGroup, + version: MachineModel.apiVersion, + }, + name: machineName, + namespace: machineNamespace, + } + : null, + ); + + const instanceType = machine && getMachineInstanceType(machine); + const region = machine && getMachineRegion(machine); + const zone = machine && getMachineZone(machine); + + return ( + + + {machineLoadError ? ( + + {machineLoadError.message || t('console-app~Unable to load Machine resources')} + + ) : !machineLoaded ? ( + + ) : !machine ? ( + {t('console-app~There is no machine associated with this node')} + ) : ( + + + + + + + + + + + + + + + + + + {machine.status?.providerStatus?.instanceState} + + + {t('console-app~Machine role')} + {getMachineRole(machine)} + + {instanceType && ( + + {t('console-app~Instance type')} + {instanceType} + + )} + {region && ( + + {t('console-app~Region')} + {region} + + )} + {zone && ( + + {t('console-app~Availability zone')} + {zone} + + )} + + {t('console-app~Machine addresses')} + + + + + + + + )} + + ); +}; + +export default MachineDetails; diff --git a/frontend/packages/console-app/src/components/nodes/configuration/machine/__tests__/BMCConfiguration.spec.tsx b/frontend/packages/console-app/src/components/nodes/configuration/machine/__tests__/BMCConfiguration.spec.tsx new file mode 100644 index 00000000000..4af645da005 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/machine/__tests__/BMCConfiguration.spec.tsx @@ -0,0 +1,161 @@ +import { screen } from '@testing-library/react'; +import type { K8sResourceKind } from '@console/dynamic-plugin-sdk/src'; +import type { NodeKind } from '@console/internal/module/k8s'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { + useIsBareMetalPluginActive, + useWatchBareMetalHost, +} from '../../../utils/NodeBareMetalUtils'; +import BMCConfiguration from '../BMCConfiguration'; + +jest.mock('@console/internal/components/utils', () => ({ + SectionHeading: jest.fn(({ text }) =>

{text}

), +})); + +jest.mock('../../../utils/NodeBareMetalUtils', () => { + const actual = jest.requireActual('../../../utils/NodeBareMetalUtils'); + return { + ...actual, + useIsBareMetalPluginActive: jest.fn(), + useWatchBareMetalHost: jest.fn(), + }; +}); + +const useIsBareMetalPluginActiveMock = useIsBareMetalPluginActive as jest.Mock; +const useWatchBareMetalHostMock = useWatchBareMetalHost as jest.Mock; + +describe('BMCConfiguration', () => { + 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', + uid: 'host-uid', + }, + spec: { + online: true, + bmc: { + address: 'redfish://192.168.1.10', + }, + bootMACAddress: '52:54:00:ab:cd:ef', + }, + status: { + poweredOn: true, + hardware: { + nics: [{ ip: '10.0.0.1' }, { ip: '10.0.0.2' }], + systemVendor: { + manufacturer: 'VendorCo', + productName: 'ServerOne', + }, + firmware: { + bmcVersion: '1.2.3', + }, + }, + goodCredentials: { + credentials: { + name: 'bmc-secret', + namespace: 'openshift-machine-api', + }, + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render nothing when the bare metal plugin is not active', () => { + useIsBareMetalPluginActiveMock.mockReturnValue(false); + useWatchBareMetalHostMock.mockReturnValue([null, false, undefined]); + + const { container } = renderWithProviders(); + + expect(container.firstChild).toBeNull(); + }); + + it('should call useWatchBareMetalHost with the node even when the plugin is inactive', () => { + useIsBareMetalPluginActiveMock.mockReturnValue(false); + useWatchBareMetalHostMock.mockReturnValue([null, false, undefined]); + + renderWithProviders(); + + expect(useWatchBareMetalHostMock).toHaveBeenCalledWith(mockNode); + }); + + it('should show loading skeletons while BMC data is loading', () => { + useIsBareMetalPluginActiveMock.mockReturnValue(true); + useWatchBareMetalHostMock.mockReturnValue([null, false, undefined]); + + const { container } = renderWithProviders(); + + expect(screen.getByRole('heading', { name: 'BMC Configuration' })).toBeInTheDocument(); + expect(container.querySelectorAll('.skeleton-detail-view').length).toBeGreaterThan(0); + }); + + it('should display an error alert when loading the bare metal host fails', () => { + useIsBareMetalPluginActiveMock.mockReturnValue(true); + useWatchBareMetalHostMock.mockReturnValue([null, true, new Error('Failed to load BMC')]); + + renderWithProviders(); + + expect(screen.getByText('Unable to load BMC configuration')).toBeInTheDocument(); + expect(screen.getByText('Failed to load BMC')).toBeInTheDocument(); + }); + + it('should display a message when no bare metal host is associated with the node', () => { + useIsBareMetalPluginActiveMock.mockReturnValue(true); + useWatchBareMetalHostMock.mockReturnValue([null, true, undefined]); + + renderWithProviders(); + + expect( + screen.getByText('There is no BMC configuration associated with this node'), + ).toBeInTheDocument(); + }); + + it('should render host BMC details when a bare metal host is loaded', () => { + useIsBareMetalPluginActiveMock.mockReturnValue(true); + useWatchBareMetalHostMock.mockReturnValue([mockBareMetalHost, true, undefined]); + + renderWithProviders(); + + expect(screen.getByText('Host addresses')).toBeInTheDocument(); + expect(screen.getAllByText('redfish://192.168.1.10')).toHaveLength(2); + expect(screen.getByText('10.0.0.1, 10.0.0.2')).toBeInTheDocument(); + expect(screen.getByText('52:54:00:ab:cd:ef')).toBeInTheDocument(); + + expect(screen.getByText('Details')).toBeInTheDocument(); + expect(screen.getByText('VendorCo ServerOne (Redfish)')).toBeInTheDocument(); + expect(screen.getByText('1.2.3')).toBeInTheDocument(); + expect(screen.getByText('On')).toBeInTheDocument(); + expect(screen.getByText('bmc-secret (namespace openshift-machine-api)')).toBeInTheDocument(); + }); + + it('should show Detached power-related status when the host is detached', () => { + useIsBareMetalPluginActiveMock.mockReturnValue(true); + const detachedHost: K8sResourceKind = { + ...mockBareMetalHost, + status: { + ...mockBareMetalHost.status, + operationalStatus: 'detached', + }, + }; + useWatchBareMetalHostMock.mockReturnValue([detachedHost, true, undefined]); + + renderWithProviders(); + + expect(screen.getByText('Detached')).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/machine/__tests__/NodeMachine.spec.tsx similarity index 70% rename from frontend/packages/console-app/src/components/nodes/configuration/__tests__/NodeMachine.spec.tsx rename to frontend/packages/console-app/src/components/nodes/configuration/machine/__tests__/NodeMachine.spec.tsx index 75ac01a3bca..6e6cb7dad4f 100644 --- a/frontend/packages/console-app/src/components/nodes/configuration/__tests__/NodeMachine.spec.tsx +++ b/frontend/packages/console-app/src/components/nodes/configuration/machine/__tests__/NodeMachine.spec.tsx @@ -3,7 +3,8 @@ import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/h import type { NodeKind } from '@console/internal/module/k8s'; import { getNodeMachineNameAndNamespace } from '@console/shared/src/selectors/node'; import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; -import NodeMachine from '../NodeMachine'; +import OperatingSystem from '../../OperatingSystem'; +import MachineDetails from '../MachineDetails'; jest.mock('@console/dynamic-plugin-sdk/src/utils/k8s/hooks', () => { const actual = jest.requireActual('@console/dynamic-plugin-sdk/src/utils/k8s/hooks'); @@ -35,10 +36,15 @@ jest.mock('@console/shared/src/selectors/node', () => ({ getNodeMachineNameAndNamespace: jest.fn(), })); +jest.mock('../BMCConfiguration', () => ({ + __esModule: true, + default: () => null, +})); + const getNodeMachineNameAndNamespaceMock = getNodeMachineNameAndNamespace as jest.Mock; const useK8sWatchResourceMock = useK8sWatchResource as jest.Mock; -describe('NodeMachine', () => { +describe('MachineDetails', () => { const mockNode: NodeKind = { apiVersion: 'v1', kind: 'Node', @@ -88,62 +94,22 @@ describe('NodeMachine', () => { 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 } = renderWithProviders(); - - 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]); - - renderWithProviders(); - - expect(screen.getByText('Error loading machine')).toBeInTheDocument(); - }); - - it('should display message when machine is not found', () => { - useK8sWatchResourceMock - .mockReturnValueOnce([null, true, undefined]) - .mockReturnValueOnce([[], true, undefined]); - - renderWithProviders(); - - expect(screen.getByText('There is no machine associated with this node')).toBeInTheDocument(); - }); - - it('should display machine details when machine is loaded', () => { - useK8sWatchResourceMock - .mockReturnValueOnce([mockMachine, true, undefined]) - .mockReturnValueOnce([[mockMachineConfigPool], true, undefined]); - - renderWithProviders(); - - expect(screen.getByText('test-machine')).toBeInTheDocument(); + useK8sWatchResourceMock.mockReset(); }); 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')]); + useK8sWatchResourceMock.mockReturnValue([null, true, new Error('Failed to load')]); - renderWithProviders(); + renderWithProviders(); - expect(screen.getByText('Error loading machine config pool')).toBeInTheDocument(); + expect(screen.getByText('MachineConfigPools are not available')).toBeInTheDocument(); + expect(screen.getByText('Failed to load')).toBeInTheDocument(); }); it('should display message when machine config pool is not found', () => { - useK8sWatchResourceMock - .mockReturnValueOnce([mockMachine, true, undefined]) - .mockReturnValueOnce([[], true, undefined]); + useK8sWatchResourceMock.mockReturnValue([[], true, undefined]); - renderWithProviders(); + renderWithProviders(); expect( screen.getByText('There is no MachineConfigPool associated with this node'), @@ -151,11 +117,9 @@ describe('NodeMachine', () => { }); it('should display machine config pool details when loaded', () => { - useK8sWatchResourceMock - .mockReturnValueOnce([mockMachine, true, undefined]) - .mockReturnValueOnce([[mockMachineConfigPool], true, undefined]); + useK8sWatchResourceMock.mockReturnValue([[mockMachineConfigPool], true, undefined]); - renderWithProviders(); + renderWithProviders(); expect(screen.getByText('test-mcp')).toBeInTheDocument(); expect(screen.getByText('MachineConfigs')).toBeInTheDocument(); @@ -170,20 +134,51 @@ describe('NodeMachine', () => { }, }; - useK8sWatchResourceMock - .mockReturnValueOnce([mockMachine, true, undefined]) - .mockReturnValueOnce([[pausedMCP], true, undefined]); + useK8sWatchResourceMock.mockReturnValue([[pausedMCP], true, undefined]); - renderWithProviders(); + renderWithProviders(); expect(screen.getByText('Workload paused alert')).toBeInTheDocument(); }); + it('should show loading skeleton when data is loading', () => { + useK8sWatchResourceMock.mockReturnValue([null, false, undefined]); + + const { container } = renderWithProviders(); + + expect(container.querySelector('[data-test="skeleton-detail-view"]')).toBeInTheDocument(); + }); + + it('should display error message when machine fails to load', () => { + useK8sWatchResourceMock.mockReturnValue([null, true, new Error('Failed to load')]); + + renderWithProviders(); + + expect(screen.getByText('Machine is not available')).toBeInTheDocument(); + expect(screen.getByText('Failed to load')).toBeInTheDocument(); + }); + + it('should display message when machine is not found', () => { + useK8sWatchResourceMock.mockReturnValue([null, true, undefined]); + + renderWithProviders(); + + expect(screen.getByText('There is no machine associated with this node')).toBeInTheDocument(); + }); + + it('should display machine details when machine is loaded', () => { + useK8sWatchResourceMock.mockReturnValue([mockMachine, true, undefined]); + + renderWithProviders(); + + expect(screen.getByText('test-machine')).toBeInTheDocument(); + }); + it('should not watch machine when node has no machine annotation', () => { getNodeMachineNameAndNamespaceMock.mockReturnValue([null, null]); useK8sWatchResourceMock.mockReturnValue([null, false, undefined]); - renderWithProviders(); + renderWithProviders(); 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 index bf576e5f0b8..7806606b27f 100644 --- 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 @@ -1,5 +1,5 @@ import type { FC } from 'react'; -import { Title } from '@patternfly/react-core'; +import { Alert, Title } from '@patternfly/react-core'; import { useTranslation } from 'react-i18next'; import { DASH } from '@console/dynamic-plugin-sdk/src/app/constants'; import { humanizeDecimalBytes } from '@console/internal/components/utils/units'; @@ -63,7 +63,9 @@ const LocalDisks: FC = ({ node }) => { {!bareMetalHostLoaded ? (
) : bareMetalHostLoadError ? ( - t('console-app~Unable to load local disks') + + {bareMetalHostLoadError.message || t('console-app~Unable to load BareMetalHost')} + ) : (
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 index 9ece47908d4..6e6d65a5315 100644 --- 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 @@ -1,6 +1,6 @@ import type { FC } from 'react'; import { useMemo } from 'react'; -import { Title } from '@patternfly/react-core'; +import { Alert, Title } from '@patternfly/react-core'; import { useTranslation } from 'react-i18next'; import type { K8sResourceCommon } from '@console/dynamic-plugin-sdk/src'; import { DASH } from '@console/dynamic-plugin-sdk/src/app/constants'; @@ -153,6 +153,7 @@ const PersistentVolumes: FC = ({ node }) => { kind: PersistentVolumeClaimModel.kind, }, isList: true, + namespaced: true, }); const [dataVolumes, dataVolumesLoaded, dataVolumesLoadError] = useAccessibleResources< K8sResourceCommon @@ -176,26 +177,13 @@ const PersistentVolumes: FC = ({ node }) => { fieldSelector: `spec.nodeName=${node.metadata.name}`, }); - const loadError = - persistentVolumesLoadError || - pvcsLoadError || - dataVolumesLoadError || - vmsLoadError || - podsLoadError; + const loadError = persistentVolumesLoadError || pvcsLoadError || podsLoadError; const isLoading = !persistentVolumesLoaded || !pvcsLoaded || !dataVolumesLoaded || !vmsLoaded || !podsLoaded; + const vmDataLoadError = vmsLoadError || dataVolumesLoadError; const vmPVCs = useMemo(() => { - if ( - persistentVolumesLoadError || - !persistentVolumesLoaded || - pvcsLoadError || - !pvcsLoaded || - dataVolumesLoadError || - !dataVolumesLoaded || - !vmsLoaded || - vmsLoadError - ) { + if (loadError || vmDataLoadError || isLoading) { return []; } return ( @@ -241,20 +229,7 @@ const PersistentVolumes: FC = ({ node }) => { return acc; }, []) ?? [] ); - }, [ - dataVolumes, - dataVolumesLoadError, - dataVolumesLoaded, - persistentVolumes, - persistentVolumesLoadError, - persistentVolumesLoaded, - pvcs, - pvcsLoadError, - pvcsLoaded, - vms, - vmsLoaded, - vmsLoadError, - ]); + }, [loadError, vmDataLoadError, isLoading, pvcs, persistentVolumes, dataVolumes, vms]); const nodePVCs = useMemo(() => { if (persistentVolumesLoadError || !persistentVolumesLoaded || pvcsLoadError || !pvcsLoaded) { @@ -310,7 +285,13 @@ const PersistentVolumes: FC = ({ node }) => { {isLoading ? (
) : loadError ? ( - t('console-app~Unable to load persistent volumes') + + {loadError.message ?? null} + + ) : nodePersistentVolumeData.length === 0 && vmDataLoadError ? ( + + {vmDataLoadError.message ?? null} + ) : (
diff --git a/frontend/packages/console-app/src/components/nodes/utils/NodeBareMetalUtils.ts b/frontend/packages/console-app/src/components/nodes/utils/NodeBareMetalUtils.ts index 4f9c5cb84c5..9edfdb2a5df 100644 --- a/frontend/packages/console-app/src/components/nodes/utils/NodeBareMetalUtils.ts +++ b/frontend/packages/console-app/src/components/nodes/utils/NodeBareMetalUtils.ts @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useAccessibleResources } from '@console/app/src/components/nodes/utils/useAccessibleResources'; import type { K8sGroupVersionKind, @@ -12,6 +13,9 @@ import { getName, getNodeMachineNameAndNamespace } from '@console/shared/src'; export const BAREMETAL_FLAG = 'BAREMETAL'; +export const HOST_STATUS_UNMANAGED = 'unmanaged'; +export const HOST_STATUS_DETACHED = 'detached'; + export const BareMetalHostModel: K8sKind = { label: 'Bare Metal Host', labelPlural: 'Bare Metal Hosts', @@ -66,7 +70,14 @@ export const findBareMetalHostByNode = ( export const useWatchBareMetalHost = ( node: NodeKind, ): WatchK8sResult => { + const { t } = useTranslation(); + const isBareMetalPluginActive = useIsBareMetalPluginActive(); + const pluginError = !isBareMetalPluginActive + ? { + message: t('console-app~Unable to load BareMetalHost'), + } + : undefined; const [bareMetalHosts, bareMetalHostsLoaded, bareMetalHostsLoadError] = useAccessibleResources< K8sResourceKind @@ -101,7 +112,7 @@ export const useWatchBareMetalHost = ( return [ bareMetalHost, bareMetalHostsLoaded && machinesLoaded, - bareMetalHostsLoadError || machinesLoadError, + bareMetalHostsLoadError || machinesLoadError || pluginError, ]; }; @@ -112,3 +123,19 @@ export const metricsFromBareMetalHosts = ( nics: bareMetalHost?.status?.hardware?.nics?.length, cpus: bareMetalHost?.status?.hardware?.cpu?.count, }); + +const ANNOTATION_HOST_RESTART = 'reboot.metal3.io'; + +export const getPoweroffAnnotation = (host: K8sResourceKind): string | undefined => + Object.keys(host?.metadata?.annotations || {}).find((annotation) => + annotation.startsWith(`${ANNOTATION_HOST_RESTART}/`), + ); + +export const hasRebootAnnotation = (host: K8sResourceKind): boolean => + !!Object.keys(host?.metadata?.annotations || {}).find( + (annotation) => annotation === ANNOTATION_HOST_RESTART, + ); +export const isHostOnline = (host: K8sResourceKind): boolean => host?.spec?.online ?? false; +export const isHostPoweredOn = (host: K8sResourceKind): boolean => host?.status?.poweredOn ?? false; +export const isHostScheduledForRestart = (host: K8sResourceKind) => + !!hasRebootAnnotation(host) && isHostPoweredOn(host); diff --git a/frontend/packages/console-app/src/components/nodes/utils/NodeVmUtils.ts b/frontend/packages/console-app/src/components/nodes/utils/NodeVmUtils.ts index 1d861de0e3c..4b43385f4d1 100644 --- a/frontend/packages/console-app/src/components/nodes/utils/NodeVmUtils.ts +++ b/frontend/packages/console-app/src/components/nodes/utils/NodeVmUtils.ts @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { useIsKubevirtPluginActive } from '@console/app/src/utils/kubevirt'; import type { K8sGroupVersionKind, @@ -51,7 +52,15 @@ export const filterVirtualMachineInstancesByNode = (vmis: K8sResourceKind[], nod export const useWatchVirtualMachineInstances = ( nodeName?: string, ): WatchK8sResult => { + const { t } = useTranslation(); + const isKubevirtPluginActive = useIsKubevirtPluginActive(); + const pluginError = !isKubevirtPluginActive + ? { + message: t('console-app~Unable to load VirtualMachines'), + } + : undefined; + const [ virtualMachineInstances, virtualMachineInstancesLoaded, @@ -74,7 +83,7 @@ export const useWatchVirtualMachineInstances = ( return [ nodeVirtualMachineInstances, virtualMachineInstancesLoaded, - virtualMachineInstancesLoadError, + virtualMachineInstancesLoadError || pluginError, ]; }; From 684b6f0a7f90889458af29e59103c4bc8dbba15c Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Date: Mon, 6 Apr 2026 17:01:54 -0400 Subject: [PATCH 3/5] CONSOLE-4950: Add high availability section to Configuration --- .../console-app/locales/en/console-app.json | 43 ++- .../nodes/configuration/NodeConfiguration.tsx | 8 + .../__tests__/NodeConfiguration.spec.tsx | 3 +- .../high-availability/Details.tsx | 179 ++++++++++++ .../high-availability/HealthChecks.tsx | 138 +++++++++ .../high-availability/HighAvailability.tsx | 102 +++++++ .../high-availability/RemediationAgent.tsx | 158 +++++++++++ .../nodes/node-dashboard/NodeHealth.tsx | 7 +- .../nodes/utils/HealthCheckUtils.ts | 209 ++++++++++++++ .../utils/estimatedRecoveryRemediation.ts | 267 ++++++++++++++++++ .../src/components/nodes/utils/utils.ts | 56 ++++ 11 files changed, 1165 insertions(+), 5 deletions(-) create mode 100644 frontend/packages/console-app/src/components/nodes/configuration/high-availability/Details.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/configuration/high-availability/HealthChecks.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/configuration/high-availability/HighAvailability.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/configuration/high-availability/RemediationAgent.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/utils/HealthCheckUtils.ts create mode 100644 frontend/packages/console-app/src/components/nodes/utils/estimatedRecoveryRemediation.ts create mode 100644 frontend/packages/console-app/src/components/nodes/utils/utils.ts diff --git a/frontend/packages/console-app/locales/en/console-app.json b/frontend/packages/console-app/locales/en/console-app.json index e9a6feeaa79..3f4892e37c1 100644 --- a/frontend/packages/console-app/locales/en/console-app.json +++ b/frontend/packages/console-app/locales/en/console-app.json @@ -352,6 +352,36 @@ "Unpin": "Unpin", "Remove from navigation?": "Remove from navigation?", "Remove": "Remove", + "auto-reboot": "auto-reboot", + "machine replacement": "machine replacement", + "template remediation": "template remediation", + "{{prefix}}: {{remediation}}; Drain: {{timeout}} timeout": "{{prefix}}: {{remediation}}; Drain: {{timeout}} timeout", + "{{prefix}}: {{remediation}}": "{{prefix}}: {{remediation}}", + "{{minMinutes}}-{{maxMinutes}} min": "{{minMinutes}}-{{maxMinutes}} min", + "Details": "Details", + "Unable to load high availability details": "Unable to load high availability details", + "Ready": "Ready", + "Unavailable": "Unavailable", + "Remediation": "Remediation", + "Estimated recovery time": "Estimated recovery time", + "Machine/Node health checks": "Machine/Node health checks", + "Unable to load health checks": "Unable to load health checks", + "Scope": "Scope", + "Selector": "Selector", + "Unhealthy conditions": "Unhealthy conditions", + "Last triggered": "Last triggered", + "No matching MachineHealthChecks or NodeHealthChecks": "No matching MachineHealthChecks or NodeHealthChecks", + "High availability": "High availability", + "SNR - Self Node Remediation": "SNR - Self Node Remediation", + "FAR - Fence Agent Remediation": "FAR - Fence Agent Remediation", + "MDR - Metal3-driven Remediation": "MDR - Metal3-driven Remediation", + "Unknown remediation": "Unknown remediation", + "Node remediation agents": "Node remediation agents", + "Unable to load remediation agents": "Unable to load remediation agents", + "Triggered by": "Triggered by", + "Config object": "Config object", + "Last action": "Last action", + "No matching remediation actions": "No matching remediation actions", "Host addresses": "Host addresses", "Management": "Management", "NICs": "NICs", @@ -363,7 +393,6 @@ "Powering off": "Powering off", "Powering on": "Powering on", "Off": "Off", - "Details": "Details", "Address": "Address", "Firmware": "Firmware", "Power state": "Power state", @@ -501,7 +530,6 @@ "This count is based on your access permissions and might not include all virtual machines.": "This count is based on your access permissions and might not include all virtual machines.", "{{formattedCores}} cores / {{totalCores}} cores": "{{formattedCores}} cores / {{totalCores}} cores", "Node": "Node", - "Ready": "Ready", "Not Ready": "Not Ready", "Discovered": "Discovered", "control-plane": "control-plane", @@ -524,6 +552,16 @@ "Certificate approval required": "Certificate approval required", "An error occurred. Please try again": "An error occurred. Please try again", "No new Pods or workloads will be placed on this Node until it's marked as schedulable.": "No new Pods or workloads will be placed on this Node until it's marked as schedulable.", + "NodeHealthCheck": "NodeHealthCheck", + "NodeHealthChecks": "NodeHealthChecks", + "All machines": "All machines", + "MachineSet {{name}}": "MachineSet {{name}}", + "Machine role {{role}}": "Machine role {{role}}", + "Selected machines": "Selected machines", + "Cluster-wide": "Cluster-wide", + "Node role {{role}}": "Node role {{role}}", + "Node roles {{roles}}": "Node roles {{roles}}", + "Selected nodes": "Selected nodes", "Unable to load VirtualMachines": "Unable to load VirtualMachines", "Identity providers": "Identity providers", "Mapping method": "Mapping method", @@ -568,7 +606,6 @@ "Min available": "Min available", "Max unavailable": "Max unavailable", "Allowed disruption": "Allowed disruption", - "Selector": "Selector", "Label query over pods whose evictions are managed by the disruption budget. Anull selector will match no pods, while an empty ({}) selector will select all pods within the namespace.": "Label query over pods whose evictions are managed by the disruption budget. Anull selector will match no pods, while an empty ({}) selector will select all pods within the namespace.", "Resource is already covered by another PodDisruptionBudget": "Resource is already covered by another PodDisruptionBudget", "Availability requirement value": "Availability requirement value", diff --git a/frontend/packages/console-app/src/components/nodes/configuration/NodeConfiguration.tsx b/frontend/packages/console-app/src/components/nodes/configuration/NodeConfiguration.tsx index f742b654ffb..2689aab6998 100644 --- a/frontend/packages/console-app/src/components/nodes/configuration/NodeConfiguration.tsx +++ b/frontend/packages/console-app/src/components/nodes/configuration/NodeConfiguration.tsx @@ -1,6 +1,7 @@ import type { FC } from 'react'; import type { NodeKind } from '@console/dynamic-plugin-sdk/src'; import { NodeSubNavPage } from '../NodeSubNavPage'; +import HighAvailability from './high-availability/HighAvailability'; import Machine from './machine/Machine'; import NodeStorage from './node-storage/NodeStorage'; import OperatingSystem from './OperatingSystem'; @@ -31,6 +32,13 @@ const standardPages = [ component: Machine, priority: 40, }, + { + tabId: 'high-availability', + // t('console-app~High availability') + nameKey: 'console-app~High availability', + component: HighAvailability, + priority: 30, + }, ]; export const NodeConfiguration: FC = ({ obj }) => ( 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 index f781665283d..17fe50bbc2e 100644 --- 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 @@ -200,7 +200,7 @@ describe('NodeConfiguration', () => { page: { tabId: 'low-priority-tab', name: 'Low Priority', - priority: 30, + priority: 10, }, component: jest.fn(() => 'LowPriorityComponent'), }, @@ -232,6 +232,7 @@ describe('NodeConfiguration', () => { 'Storage', 'Operating system', 'Machine', + 'High availability', 'Low Priority', ]); }); diff --git a/frontend/packages/console-app/src/components/nodes/configuration/high-availability/Details.tsx b/frontend/packages/console-app/src/components/nodes/configuration/high-availability/Details.tsx new file mode 100644 index 00000000000..637870d59d2 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/high-availability/Details.tsx @@ -0,0 +1,179 @@ +import type { FC } from 'react'; +import { useMemo } from 'react'; +import { + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Skeleton, + Title, +} from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import Status from '@console/dynamic-plugin-sdk/src/app/components/status/Status'; +import type { MachineHealthCheckKind } from '@console/internal/module/k8s'; +import { DASH } from '@console/shared/src/constants/ui'; +import { + computeRemediationTimeBoundsFromRefs, + dedupeRemediationTemplateRefs, + FALLBACK_REMEDIATION_BOUNDS, + getRemediationTemplateRefsFromHealthChecks, + useRemediationResourcesForEstimatedRecovery, +} from '../../utils/estimatedRecoveryRemediation'; +import type { NodeHealthCheckKind } from '../../utils/HealthCheckUtils'; +import { formatTimeoutForDisplay, getMaxTimeoutFromConditions } from '../../utils/utils'; + +type DetailsProps = { + matchingMachineHealthChecks: MachineHealthCheckKind[]; + matchingNodeHealthChecks: NodeHealthCheckKind[]; + isLoading: boolean; + loadError?: unknown; +}; + +const NODE_HEARTBEAT_DETECTION_SECONDS = 50; +const WORKLOAD_RESTART_SECONDS = 15; + +const Details: FC = ({ + matchingMachineHealthChecks, + matchingNodeHealthChecks, + isLoading, + loadError, +}) => { + const { t } = useTranslation(); + const { + snrConfigs, + farTemplates, + loaded: remediationResourcesLoaded, + } = useRemediationResourcesForEstimatedRecovery(); + + const isLoadingDetailsData = isLoading || !remediationResourcesLoaded; + + const isHighAvailability = useMemo( + () => + getRemediationTemplateRefsFromHealthChecks( + matchingMachineHealthChecks, + matchingNodeHealthChecks, + ).length > 0, + [matchingMachineHealthChecks, matchingNodeHealthChecks], + ); + + const remediationDisplay = useMemo(() => { + const primaryMHC = matchingMachineHealthChecks[0]; + const primaryNHC = matchingNodeHealthChecks[0]; + const source = primaryMHC + ? ({ prefix: 'MHC', check: primaryMHC } as const) + : primaryNHC + ? ({ prefix: 'NHC', check: primaryNHC } as const) + : undefined; + if (!source) { + return DASH; + } + + const reboot = + source.prefix === 'MHC' && + source.check.metadata?.annotations?.['machine.openshift.io/remediation-strategy'] === + 'external-baremetal'; + const baseRemediation = reboot + ? t('console-app~auto-reboot') + : source.prefix === 'MHC' + ? t('console-app~machine replacement') + : t('console-app~template remediation'); + + const unhealthyConditions = source.check.spec?.unhealthyConditions ?? []; + const maxTimeoutSeconds = getMaxTimeoutFromConditions(unhealthyConditions); + + if (maxTimeoutSeconds) { + return t('console-app~{{prefix}}: {{remediation}}; Drain: {{timeout}} timeout', { + prefix: source.prefix, + remediation: baseRemediation, + timeout: formatTimeoutForDisplay(maxTimeoutSeconds), + }); + } + + return t('console-app~{{prefix}}: {{remediation}}', { + prefix: source.prefix, + remediation: baseRemediation, + }); + }, [matchingMachineHealthChecks, matchingNodeHealthChecks, t]); + + const estimatedRecoveryTimeDisplay = useMemo(() => { + const allConditions = [ + ...matchingMachineHealthChecks.flatMap((hc) => hc.spec?.unhealthyConditions ?? []), + ...matchingNodeHealthChecks.flatMap((hc) => hc.spec?.unhealthyConditions ?? []), + ]; + const maxTimeoutSeconds = getMaxTimeoutFromConditions(allConditions); + + if (maxTimeoutSeconds === undefined) { + return undefined; + } + + const orderedRefs = dedupeRemediationTemplateRefs( + getRemediationTemplateRefsFromHealthChecks( + matchingMachineHealthChecks, + matchingNodeHealthChecks, + ), + ); + const remediationBounds = + computeRemediationTimeBoundsFromRefs(orderedRefs, snrConfigs, farTemplates) ?? + FALLBACK_REMEDIATION_BOUNDS; + + // Recovery estimate model from HA guidance: + // 50s node heartbeat detection + health-check timeout + remediation time + ~15s workload restart. + const baseSeconds = + NODE_HEARTBEAT_DETECTION_SECONDS + maxTimeoutSeconds + WORKLOAD_RESTART_SECONDS; + const minMinutes = Math.max(1, Math.ceil((baseSeconds + remediationBounds.minSeconds) / 60)); + const maxMinutes = Math.max( + minMinutes, + Math.ceil((baseSeconds + remediationBounds.maxSeconds) / 60), + ); + return t('console-app~{{minMinutes}}-{{maxMinutes}} min', { minMinutes, maxMinutes }); + }, [matchingMachineHealthChecks, matchingNodeHealthChecks, snrConfigs, farTemplates, t]); + + return ( + <> + + <span>{t('console-app~Details')}</span> + + {loadError ? ( + t('console-app~Unable to load high availability details') + ) : ( + + + {t('console-app~Status')} + + {isLoadingDetailsData ? ( + + ) : ( + + )} + + + + {t('console-app~Remediation')} + + {isLoadingDetailsData ? : remediationDisplay} + + + + {t('console-app~Estimated recovery time')} + + {isLoadingDetailsData ? ( + + ) : ( + estimatedRecoveryTimeDisplay ?? DASH + )} + + + + )} + + ); +}; + +export default Details; diff --git a/frontend/packages/console-app/src/components/nodes/configuration/high-availability/HealthChecks.tsx b/frontend/packages/console-app/src/components/nodes/configuration/high-availability/HealthChecks.tsx new file mode 100644 index 00000000000..733577a14b1 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/high-availability/HealthChecks.tsx @@ -0,0 +1,138 @@ +import type { FC } from 'react'; +import { useMemo } from 'react'; +import { Title } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import { getGroupVersionKindForModel } from '@console/dynamic-plugin-sdk/src/lib-core'; +import { ResourceLink } from '@console/internal/components/utils'; +import { MachineHealthCheckModel } from '@console/internal/models'; +import type { K8sResourceKind, MachineHealthCheckKind } from '@console/internal/module/k8s'; +import { Timestamp } from '@console/shared/src/components/datetime/Timestamp'; +import type { NodeHealthCheckKind } from '../../utils/HealthCheckUtils'; +import { + formatHealthCheckSelector, + formatUnhealthyConditionsDisplay, + getMachineHealthCheckScope, + getNodeHealthCheckScope, + NodeHealthCheckModel, +} from '../../utils/HealthCheckUtils'; + +type HealthCheckRow = { + model: typeof MachineHealthCheckModel | typeof NodeHealthCheckModel; + name: string; + namespace?: string; + scopeDisplay: string; + selectorDisplay: string; + unhealthyConditionsDisplay: string; + lastTriggeredTimestamp: string | undefined; +}; + +type HealthChecksProps = { + matchingMachineHealthChecks: MachineHealthCheckKind[]; + matchingNodeHealthChecks: NodeHealthCheckKind[]; + isLoading: boolean; + loadError?: unknown; +}; + +const HealthChecks: FC = ({ + matchingMachineHealthChecks, + matchingNodeHealthChecks, + isLoading, + loadError, +}) => { + const { t } = useTranslation(); + const rows: HealthCheckRow[] = useMemo(() => { + const mhcRows: HealthCheckRow[] = matchingMachineHealthChecks.map((hc: K8sResourceKind) => ({ + model: MachineHealthCheckModel, + name: hc.metadata?.name, + namespace: hc.metadata?.namespace, + scopeDisplay: getMachineHealthCheckScope(hc.spec?.selector, t), + selectorDisplay: formatHealthCheckSelector(hc.spec?.selector), + unhealthyConditionsDisplay: formatUnhealthyConditionsDisplay(hc.spec?.unhealthyConditions), + lastTriggeredTimestamp: hc.status?.lastUpdateTime + ? new Date(hc.status.lastUpdateTime).toISOString() + : undefined, + })); + const nhcRows: HealthCheckRow[] = matchingNodeHealthChecks.map((nhc) => ({ + model: NodeHealthCheckModel, + name: nhc.metadata?.name, + scopeDisplay: getNodeHealthCheckScope(nhc.spec?.selector, t), + selectorDisplay: formatHealthCheckSelector(nhc.spec?.selector), + unhealthyConditionsDisplay: formatUnhealthyConditionsDisplay(nhc.spec?.unhealthyConditions), + lastTriggeredTimestamp: nhc.status?.lastUpdateTime + ? new Date(nhc.status.lastUpdateTime).toISOString() + : undefined, + })); + return [...mhcRows, ...nhcRows].sort((a, b) => { + const kindCompare = a.model.kind.localeCompare(b.model.kind); + if (kindCompare !== 0) { + return kindCompare; + } + return a.name.localeCompare(b.name); + }); + }, [matchingMachineHealthChecks, matchingNodeHealthChecks, t]); + + return ( + <> + + <span>{t('console-app~Machine/Node health checks')}</span> + + {isLoading ? ( +
+ ) : loadError ? ( + t('console-app~Unable to load health checks') + ) : ( +
+
+ + + + + + + + + + + {rows.length === 0 ? ( + + + + ) : ( + rows.map((row) => ( + + + + + + + + )) + )} + +
{t('console-app~Name')}{t('console-app~Scope')}{t('console-app~Selector')}{t('console-app~Unhealthy conditions')}{t('console-app~Last triggered')}
+ {t('console-app~No matching MachineHealthChecks or NodeHealthChecks')} +
+ + + {row.scopeDisplay} + + {row.selectorDisplay} + + {row.unhealthyConditionsDisplay} + + +
+
+ )} + + ); +}; + +export default HealthChecks; diff --git a/frontend/packages/console-app/src/components/nodes/configuration/high-availability/HighAvailability.tsx b/frontend/packages/console-app/src/components/nodes/configuration/high-availability/HighAvailability.tsx new file mode 100644 index 00000000000..4532912d4af --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/high-availability/HighAvailability.tsx @@ -0,0 +1,102 @@ +import type { ComponentType } from 'react'; +import { useMemo } from 'react'; +import { Flex, FlexItem } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import { getGroupVersionKindForModel } from '@console/dynamic-plugin-sdk/src/lib-core'; +import type { PageComponentProps } from '@console/internal/components/utils'; +import { SectionHeading } from '@console/internal/components/utils'; +import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; +import { MachineModel } from '@console/internal/models'; +import type { MachineKind, NodeKind } from '@console/internal/module/k8s'; +import PaneBody from '@console/shared/src/components/layout/PaneBody'; +import { getNodeMachineNameAndNamespace } from '@console/shared/src/selectors/node'; +import { + filterMachineHealthChecksForMachine, + filterNodeHealthChecksForNode, + useWatchMachineHealthChecks, + useWatchNodeHealthChecks, +} from '../../utils/HealthCheckUtils'; +import Details from './Details'; +import HealthChecks from './HealthChecks'; +import RemediationAgent from './RemediationAgent'; + +const HighAvailability: ComponentType> = ({ obj: node }) => { + const { t } = useTranslation(); + + const [machineName, machineNamespace] = getNodeMachineNameAndNamespace(node); + const hasMachineRef = Boolean(machineName && machineNamespace); + + const [machine, machineLoaded, machineLoadError] = useK8sWatchResource( + hasMachineRef + ? { + groupVersionKind: getGroupVersionKindForModel(MachineModel), + name: machineName, + namespace: machineNamespace, + } + : undefined, + ); + const [ + machineHealthChecks, + machineHealthChecksLoaded, + machineHealthChecksLoadError, + ] = useWatchMachineHealthChecks(); + const [ + nodeHealthChecks, + nodeHealthChecksLoaded, + nodeHealthChecksLoadError, + ] = useWatchNodeHealthChecks(); + + const matchingMachineHealthChecks = useMemo(() => { + if (!machineHealthChecksLoaded || !hasMachineRef || machineLoadError || !machine) { + return []; + } + return filterMachineHealthChecksForMachine(machineHealthChecks ?? [], machine); + }, [machine, machineHealthChecks, machineHealthChecksLoaded, hasMachineRef, machineLoadError]); + + const matchingNodeHealthChecks = useMemo(() => { + if (!nodeHealthChecksLoaded) { + return []; + } + return filterNodeHealthChecksForNode(nodeHealthChecks ?? [], node); + }, [node, nodeHealthChecks, nodeHealthChecksLoaded]); + + const loadError = machineHealthChecksLoadError || nodeHealthChecksLoadError; + const isLoading = + !machineHealthChecksLoaded || + !nodeHealthChecksLoaded || + (hasMachineRef && !machineLoaded && !machineLoadError); + + return ( + + + + +
+ + + + + + + + + + ); +}; + +export default HighAvailability; diff --git a/frontend/packages/console-app/src/components/nodes/configuration/high-availability/RemediationAgent.tsx b/frontend/packages/console-app/src/components/nodes/configuration/high-availability/RemediationAgent.tsx new file mode 100644 index 00000000000..36d962f1f70 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/high-availability/RemediationAgent.tsx @@ -0,0 +1,158 @@ +import type { FC } from 'react'; +import { useMemo } from 'react'; +import { Title } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import { getGroupVersionKindForModel } from '@console/dynamic-plugin-sdk/src/lib-core'; +import { ResourceLink } from '@console/internal/components/utils'; +import { MachineHealthCheckModel } from '@console/internal/models'; +import type { MachineHealthCheckKind } from '@console/internal/module/k8s'; +import { groupVersionFor } from '@console/internal/module/k8s'; +import { Timestamp } from '@console/shared/src/components/datetime/Timestamp'; +import { DASH } from '@console/shared/src/constants'; +import type { RemediationTemplateRef } from '../../utils/estimatedRecoveryRemediation'; +import { getRemediationTemplateRefsFromHealthCheck } from '../../utils/estimatedRecoveryRemediation'; +import type { NodeHealthCheckKind } from '../../utils/HealthCheckUtils'; +import { getHealthCheckLastAction, NodeHealthCheckModel } from '../../utils/HealthCheckUtils'; + +type RemediationAgentRow = { + typeLabel: string; + triggeredByModel: typeof MachineHealthCheckModel | typeof NodeHealthCheckModel; + triggeredByName: string; + triggeredByNamespace?: string; + configRef?: RemediationTemplateRef; + lastAction?: string; + rowKey: string; +}; + +type RemediationAgentProps = { + matchingMachineHealthChecks: MachineHealthCheckKind[]; + matchingNodeHealthChecks: NodeHealthCheckKind[]; + isLoading: boolean; + loadError?: unknown; +}; + +const getTemplateTypeLabel = ( + templateKind: string | undefined, + t: ReturnType['t'], +) => { + switch (templateKind) { + case 'SelfNodeRemediationTemplate': + return t('console-app~SNR - Self Node Remediation'); + case 'FenceAgentsRemediationTemplate': + return t('console-app~FAR - Fence Agent Remediation'); + case 'MachineDeletionRemediationTemplate': + case 'Metal3RemediationTemplate': + return t('console-app~MDR - Metal3-driven Remediation'); + default: + return templateKind ?? t('console-app~Unknown remediation'); + } +}; + +const RemediationAgent: FC = ({ + matchingMachineHealthChecks, + matchingNodeHealthChecks, + isLoading, + loadError, +}) => { + const { t } = useTranslation(); + + const rows: RemediationAgentRow[] = useMemo(() => { + const toRows = ( + healthCheck: MachineHealthCheckKind | NodeHealthCheckKind, + model: typeof MachineHealthCheckModel | typeof NodeHealthCheckModel, + ): RemediationAgentRow[] => { + const templateRefs = getRemediationTemplateRefsFromHealthCheck(healthCheck); + const lastAction = getHealthCheckLastAction(healthCheck); + return templateRefs.map((configRef, idx) => ({ + typeLabel: getTemplateTypeLabel(configRef.kind, t), + triggeredByModel: model, + triggeredByName: healthCheck.metadata?.name, + triggeredByNamespace: healthCheck.metadata?.namespace, + configRef: { + ...configRef, + namespace: configRef.namespace ?? healthCheck.metadata?.namespace, + }, + lastAction, + rowKey: `${model.id}-${healthCheck.metadata?.namespace ?? ''}-${ + healthCheck.metadata?.name + }-${configRef.kind ?? ''}-${configRef.name ?? ''}-${idx}`, + })); + }; + + const machineRows = matchingMachineHealthChecks.flatMap((healthCheck) => + toRows(healthCheck, MachineHealthCheckModel), + ); + const nodeRows = matchingNodeHealthChecks.flatMap((healthCheck) => + toRows(healthCheck, NodeHealthCheckModel), + ); + + return [...machineRows, ...nodeRows].sort((a, b) => a.typeLabel.localeCompare(b.typeLabel)); + }, [matchingMachineHealthChecks, matchingNodeHealthChecks, t]); + + return ( + <> + + <span>{t('console-app~Node remediation agents')}</span> + + {isLoading ? ( +
+ ) : loadError ? ( + t('console-app~Unable to load remediation agents') + ) : ( +
+ + + + + + + + + + + {rows.length === 0 ? ( + + + + ) : ( + rows.map((row) => ( + + + + + + + )) + )} + +
{t('console-app~Type')}{t('console-app~Triggered by')}{t('console-app~Config object')}{t('console-app~Last action')}
+ {t('console-app~No matching remediation actions')} +
{row.typeLabel} + + + {row.configRef?.name && row.configRef?.apiVersion && row.configRef?.kind ? ( + + ) : ( + DASH + )} + + +
+
+ )} + + ); +}; + +export default RemediationAgent; diff --git a/frontend/packages/console-app/src/components/nodes/node-dashboard/NodeHealth.tsx b/frontend/packages/console-app/src/components/nodes/node-dashboard/NodeHealth.tsx index f88b2fccb93..d6aabb4df15 100644 --- a/frontend/packages/console-app/src/components/nodes/node-dashboard/NodeHealth.tsx +++ b/frontend/packages/console-app/src/components/nodes/node-dashboard/NodeHealth.tsx @@ -29,6 +29,7 @@ import Status, { } from '@console/shared/src/components/dashboard/status-card/StatusPopup'; import { getNodeMachineNameAndNamespace } from '@console/shared/src/selectors/node'; import NodeStatus from '../NodeStatus'; +import { parseDurationToSeconds } from '../utils/utils'; import { CONDITIONS_WARNING } from './messages'; import { NodeDashboardContext } from './NodeDashboardContext'; @@ -166,7 +167,11 @@ const isConditionFailing = ( } const transitionTime = new Date(nodeCondition.lastTransitionTime).getTime(); const currentTime = new Date().getTime(); - const withTO = transitionTime + 1000 * parseInt(timeout, 10); + const timeoutInSeconds = parseDurationToSeconds(timeout); + if (timeoutInSeconds === undefined) { + return false; + } + const withTO = transitionTime + timeoutInSeconds * 1000; return withTO < currentTime; }; diff --git a/frontend/packages/console-app/src/components/nodes/utils/HealthCheckUtils.ts b/frontend/packages/console-app/src/components/nodes/utils/HealthCheckUtils.ts new file mode 100644 index 00000000000..7190f11055d --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/utils/HealthCheckUtils.ts @@ -0,0 +1,209 @@ +import type { TFunction } from 'i18next'; +import type { + K8sResourceKind, + WatchK8sResource, + WatchK8sResult, +} from '@console/dynamic-plugin-sdk/src'; +import type { K8sModel, Selector } from '@console/dynamic-plugin-sdk/src/api/common-types'; +import type { K8sResourceCommon } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; +import { selectorToString, toRequirements } from '@console/dynamic-plugin-sdk/src/utils/k8s'; +import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; +import { MachineHealthCheckModel } from '@console/internal/models'; +import type { + MachineHealthCheckKind, + MachineHealthCondition, + MachineKind, + NodeKind, +} from '@console/internal/module/k8s'; +import { referenceForModel } from '@console/internal/module/k8s'; +import { LabelSelector } from '@console/internal/module/k8s/label-selector'; +import { DASH } from '@console/shared/src'; +import { formatDurationForDisplay } from './utils'; + +export type NodeHealthCheckUnhealthyCondition = { + type: string; + status: string; + duration?: string; +}; + +export type NodeHealthCheckRemediationEntry = { + started?: string; + timedOut?: string; + resource?: { + name?: string; + namespace?: string; + kind?: string; + apiVersion?: string; + }; + templateName?: string; +}; + +export type NodeHealthCheckUnhealthyNodeEntry = { + name: string; + remediations?: NodeHealthCheckRemediationEntry[]; + conditionsHealthyTimestamp?: string; + healthyDelayed?: boolean; +}; + +export type NodeHealthCheckKind = K8sResourceCommon & { + spec?: { + selector?: Selector; + unhealthyConditions?: NodeHealthCheckUnhealthyCondition[]; + }; + status?: { + unhealthyNodes?: NodeHealthCheckUnhealthyNodeEntry[]; + lastUpdateTime?: string; + phase?: string; + reason?: string; + observedNodes?: number; + healthyNodes?: number; + }; +}; + +export const NodeHealthCheckModel: K8sModel = { + // t('console-app~NodeHealthCheck') + label: 'NodeHealthCheck', + // t('console-app~NodeHealthChecks') + labelPlural: 'NodeHealthChecks', + apiVersion: 'v1alpha1', + apiGroup: 'remediation.medik8s.io', + plural: 'nodehealthchecks', + abbr: 'NHC', + namespaced: false, + kind: 'NodeHealthCheck', + id: 'nodehealthcheck', + crd: true, +}; + +export const CLUSTER_API_MACHINE_SET = 'machine.openshift.io/cluster-api-machineset'; +export const CLUSTER_API_MACHINE_ROLE = 'machine.openshift.io/cluster-api-machine-role'; +export const NODE_ROLE_PREFIX = 'node-role.kubernetes.io/'; + +export const nodeHealthChecksWatchResource: WatchK8sResource = { + groupVersionKind: { + group: NodeHealthCheckModel.apiGroup, + version: NodeHealthCheckModel.apiVersion, + kind: NodeHealthCheckModel.kind, + }, + isList: true, +}; + +export const useWatchMachineHealthChecks = (): WatchK8sResult => + useK8sWatchResource({ + isList: true, + kind: referenceForModel(MachineHealthCheckModel), + }); + +export const useWatchNodeHealthChecks = (): WatchK8sResult => + useK8sWatchResource(nodeHealthChecksWatchResource); + +export const isHealthCheckSelectorEmpty = (selector: Selector | undefined): boolean => + new LabelSelector(selector ?? {}).isEmpty(); + +export const getValuesForKey = (reqs: ReturnType, key: string): string[] => + reqs + .filter( + (r) => + r.key === key && + (r.operator === 'Equals' || + r.operator === 'In' || + r.operator === 'in' || + r.operator === 'Exists'), + ) + .flatMap((r) => r.values ?? []); + +export const getMachineHealthCheckScope = ( + selector: Selector | undefined, + t: TFunction, +): string => { + if (isHealthCheckSelectorEmpty(selector)) { + return t('console-app~All machines'); + } + const reqs = toRequirements(selector ?? {}); + const machineSetNames = getValuesForKey(reqs, CLUSTER_API_MACHINE_SET); + if (machineSetNames.length) { + return t('console-app~MachineSet {{name}}', { name: machineSetNames.join(', ') }); + } + const machineRoles = getValuesForKey(reqs, CLUSTER_API_MACHINE_ROLE); + if (machineRoles.length) { + return t('console-app~Machine role {{role}}', { role: machineRoles.join(', ') }); + } + return t('console-app~Selected machines'); +}; + +export const getNodeHealthCheckScope = (selector: Selector | undefined, t: TFunction): string => { + if (isHealthCheckSelectorEmpty(selector)) { + return t('console-app~Cluster-wide'); + } + const reqs = toRequirements(selector ?? {}); + const nodeRoles = new Set(); + reqs.forEach((r) => { + if ( + r.key.startsWith(NODE_ROLE_PREFIX) && + (r.operator === 'Equals' || + r.operator === 'In' || + r.operator === 'in' || + r.operator === 'Exists') + ) { + nodeRoles.add(r.key.slice(NODE_ROLE_PREFIX.length)); + } + }); + if (nodeRoles.size === 1) { + return t('console-app~Node role {{role}}', { role: [...nodeRoles][0] }); + } + if (nodeRoles.size > 1) { + return t('console-app~Node roles {{roles}}', { roles: [...nodeRoles].sort().join(', ') }); + } + return t('console-app~Selected nodes'); +}; + +export const formatHealthCheckSelector = (selector: Selector | undefined): string => { + const raw = selectorToString(selector ?? {}) + .replace(/=,/g, ',') + .replace(/=$/g, ''); + const formatted = raw.replace(/,/g, ', '); + return formatted.trim() ? formatted : DASH; +}; + +export const formatUnhealthyConditionsDisplay = ( + conditions: MachineHealthCondition[] | NodeHealthCheckUnhealthyCondition[] | undefined, +): string => { + if (!conditions?.length) { + return DASH; + } + return conditions + .map((c) => { + const duration = + 'duration' in c + ? formatDurationForDisplay(c.duration) + : formatDurationForDisplay(c.timeout); + const type = c.type ?? ''; + const status = c.status ?? ''; + if (duration) { + return `${type}=${status} for ${duration}`; + } + return `${type}=${status}`; + }) + .join(', '); +}; + +export const getHealthCheckLastAction = (healthCheck: K8sResourceKind): string | undefined => + healthCheck.status?.lastUpdateTime; + +export const filterMachineHealthChecksForMachine = ( + machineHealthChecks: MachineHealthCheckKind[], + machine: MachineKind, +): MachineHealthCheckKind[] => + machineHealthChecks.filter((hc) => { + const selector = new LabelSelector(hc.spec?.selector || {}); + return selector.matches(machine); + }); + +export const filterNodeHealthChecksForNode = ( + nodeHealthChecks: NodeHealthCheckKind[], + node: NodeKind, +): NodeHealthCheckKind[] => + nodeHealthChecks.filter((nhc) => { + const selector = new LabelSelector(nhc.spec?.selector || {}); + return selector.matches(node); + }); diff --git a/frontend/packages/console-app/src/components/nodes/utils/estimatedRecoveryRemediation.ts b/frontend/packages/console-app/src/components/nodes/utils/estimatedRecoveryRemediation.ts new file mode 100644 index 00000000000..58e4a8167d4 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/utils/estimatedRecoveryRemediation.ts @@ -0,0 +1,267 @@ +import type { K8sResourceKind } from '@console/dynamic-plugin-sdk/src'; +import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; +import { parseDurationToSeconds } from './utils'; + +export type RemediationTemplateRef = { + apiVersion?: string; + kind?: string; + name?: string; + namespace?: string; +}; + +export const getRemediationTemplateRefsFromHealthCheck = ( + healthCheck: K8sResourceKind, +): RemediationTemplateRef[] => { + const single = healthCheck?.spec?.remediationTemplate + ? [healthCheck.spec.remediationTemplate as RemediationTemplateRef] + : []; + const escalating = (healthCheck?.spec?.escalatingRemediations ?? []) + .map((entry: { remediationTemplate?: RemediationTemplateRef }) => entry?.remediationTemplate) + .filter(Boolean); + return [...single, ...escalating].filter((entry) => entry?.name); +}; + +export const getRemediationTemplateRefsFromHealthChecks = ( + machineHealthChecks: K8sResourceKind[], + nodeHealthChecks: K8sResourceKind[], +): RemediationTemplateRef[] => { + const refs: RemediationTemplateRef[] = []; + [...machineHealthChecks, ...nodeHealthChecks].forEach((hc) => { + getRemediationTemplateRefsFromHealthCheck(hc).forEach((ref) => { + refs.push({ + ...ref, + namespace: ref.namespace ?? hc.metadata?.namespace, + }); + }); + }); + return refs; +}; + +const refKey = (r: RemediationTemplateRef) => + `${r.kind ?? ''}/${r.namespace ?? ''}/${r.name ?? ''}`; + +export const dedupeRemediationTemplateRefs = ( + refs: RemediationTemplateRef[], +): RemediationTemplateRef[] => { + const seen = new Set(); + return refs.filter((r) => { + const k = refKey(r); + if (seen.has(k)) { + return false; + } + seen.add(k); + return true; + }); +}; + +export type RemediationTimeBounds = { + minSeconds: number; + maxSeconds: number; +}; + +/** When CRDs are absent or templates cannot be resolved. */ +export const FALLBACK_REMEDIATION_BOUNDS: RemediationTimeBounds = { + minSeconds: 15, + maxSeconds: 180, +}; + +const getSafeTimeToAssumeRebootSeconds = ( + spec: Record | undefined, +): number | undefined => { + if (!spec) { + return undefined; + } + const v = spec.safeTimeToAssumeNodeRebootedSeconds ?? spec.safeTimeToAssumeNodeRebootSeconds; + return typeof v === 'number' ? v : undefined; +}; + +/** + * SNR: uses SelfNodeRemediationConfig (not the template) for timeouts described in HA docs. + * Min ~ safe reboot wait; max adds worst-case API retry phase before fencing completes. + */ +export const estimateSnrRemediationBoundsFromConfig = ( + config: K8sResourceKind | undefined, +): RemediationTimeBounds => { + const spec = config?.spec as Record | undefined; + const safeTime = getSafeTimeToAssumeRebootSeconds(spec) ?? 180; + const maxApi = typeof spec?.maxApiErrorThreshold === 'number' ? spec.maxApiErrorThreshold : 3; + const apiInterval = + parseDurationToSeconds(String(spec?.apiCheckInterval)) ?? + FALLBACK_REMEDIATION_BOUNDS.minSeconds; + const apiTimeout = parseDurationToSeconds(String(spec?.apiServerTimeout)) ?? 5; + const apiPhaseMax = maxApi * (apiInterval + apiTimeout); + const peerExtra = + parseDurationToSeconds(String(spec?.peerUpdateTimeout ?? spec?.peerApiTimeout)) ?? 0; + + const minSeconds = safeTime; + const maxSeconds = safeTime + apiPhaseMax + peerExtra + 60; + + return { + minSeconds: Math.max(FALLBACK_REMEDIATION_BOUNDS.minSeconds, minSeconds), + maxSeconds: Math.max(minSeconds + 1, maxSeconds), + }; +}; + +/** + * FAR: fast path ~10–15s; max scales with fence-agent retries/timeouts from template. + */ +export const estimateFarRemediationBoundsFromTemplate = ( + template: K8sResourceKind | undefined, +): RemediationTimeBounds => { + const inner = template?.spec?.template?.spec as Record | undefined; + const retryRaw = inner?.retryLimit ?? inner?.retries; + const retry = + typeof retryRaw === 'number' + ? retryRaw + : typeof retryRaw === 'string' + ? parseInt(retryRaw, 10) + : 5; + const safeRetry = Number.isFinite(retry) && retry > 0 ? retry : 5; + const timeoutSeconds = + parseDurationToSeconds(String(inner?.timeout ?? inner?.fenceTimeout)) ?? + FALLBACK_REMEDIATION_BOUNDS.maxSeconds; + + return { + minSeconds: FALLBACK_REMEDIATION_BOUNDS.minSeconds, + maxSeconds: Math.max(FALLBACK_REMEDIATION_BOUNDS.minSeconds, safeRetry * timeoutSeconds), + }; +}; + +/** + * MDR / Metal3: no precise timing in empty templates; use conservative bounds. + */ +export const estimateMdrRemediationBoundsFromTemplate = (): RemediationTimeBounds => ({ + minSeconds: 120, + maxSeconds: 600, +}); + +const findResourceByRef = ( + ref: RemediationTemplateRef, + list: K8sResourceKind[] | undefined, + kind: string, +): K8sResourceKind | undefined => + list?.find( + (r) => + r.kind === kind && + r.metadata?.name === ref.name && + (r.metadata?.namespace ?? '') === (ref.namespace ?? ''), + ); + +const findSnrConfigForTemplateRef = ( + ref: RemediationTemplateRef, + configs: K8sResourceKind[] | undefined, +): K8sResourceKind | undefined => { + const ns = ref.namespace ?? ''; + + // Find the SNR Config for the ref's namespace or the well-defined SNR by name if none exist + return ( + configs?.find((c) => c.metadata?.namespace === ns) ?? + configs?.find((c) => c.metadata?.name === 'self-node-remediation-config') ?? + configs?.[0] + ); +}; + +/** + * Ordered refs (per health check order): first-step min, sum of max for sequential escalations. + */ +export const computeRemediationTimeBoundsFromRefs = ( + orderedRefs: RemediationTemplateRef[], + snrConfigs: K8sResourceKind[] | undefined, + farTemplates: K8sResourceKind[] | undefined, +): RemediationTimeBounds | undefined => { + if (!orderedRefs.length) { + return undefined; + } + + const boundsList: RemediationTimeBounds[] = []; + + orderedRefs.forEach((ref) => { + const kind = ref.kind ?? ''; + if (kind === 'SelfNodeRemediationTemplate') { + const cfg = findSnrConfigForTemplateRef(ref, snrConfigs); + boundsList.push(estimateSnrRemediationBoundsFromConfig(cfg)); + return; + } + if (kind === 'FenceAgentsRemediationTemplate') { + const tpl = findResourceByRef(ref, farTemplates, 'FenceAgentsRemediationTemplate'); + boundsList.push(estimateFarRemediationBoundsFromTemplate(tpl)); + return; + } + if (kind === 'MachineDeletionRemediationTemplate' || kind === 'Metal3RemediationTemplate') { + boundsList.push(estimateMdrRemediationBoundsFromTemplate()); + } + }); + + if (!boundsList.length) { + return undefined; + } + + const { minSeconds } = boundsList[0]; + const maxSeconds = boundsList.reduce((sum, b) => sum + b.maxSeconds, 0); + + return { + minSeconds, + maxSeconds: Math.max(minSeconds + 1, maxSeconds), + }; +}; + +/** + * Watches remediation-related CRs used to refine estimated recovery time. + * Missing CRDs may surface watch errors; callers should fall back to defaults. + */ +export const useRemediationResourcesForEstimatedRecovery = (): { + snrConfigs: K8sResourceKind[]; + farTemplates: K8sResourceKind[]; + mdrTemplates: K8sResourceKind[]; + metal3Templates: K8sResourceKind[]; + loaded: boolean; +} => { + const [snrConfigs, snrLoaded, snrLoadError] = useK8sWatchResource({ + isList: true, + groupVersionKind: { + group: 'self-node-remediation.medik8s.io', + version: 'v1alpha1', + kind: 'SelfNodeRemediationConfig', + }, + namespaced: true, + }); + const [farTemplates, farLoaded, farLoadError] = useK8sWatchResource({ + isList: true, + groupVersionKind: { + group: 'fence-agents-remediation.medik8s.io', + version: 'v1alpha1', + kind: 'FenceAgentsRemediationTemplate', + }, + namespaced: true, + }); + const [mdrTemplates, mdrLoaded, mdrLoadError] = useK8sWatchResource({ + isList: true, + groupVersionKind: { + group: 'machine-deletion-remediation.medik8s.io', + version: 'v1alpha1', + kind: 'MachineDeletionRemediationTemplate', + }, + namespaced: true, + }); + const [metal3Templates, metal3Loaded, metal3LoadError] = useK8sWatchResource({ + isList: true, + groupVersionKind: { + group: 'infrastructure.cluster.x-k8s.io', + version: 'v1beta1', + kind: 'Metal3RemediationTemplate', + }, + namespaced: true, + }); + + return { + snrConfigs: snrConfigs ?? [], + farTemplates: farTemplates ?? [], + mdrTemplates: mdrTemplates ?? [], + metal3Templates: metal3Templates ?? [], + loaded: + (snrLoaded || !!snrLoadError) && + (farLoaded || !!farLoadError) && + (mdrLoaded || !!mdrLoadError) && + (metal3Loaded || !!metal3LoadError), + }; +}; diff --git a/frontend/packages/console-app/src/components/nodes/utils/utils.ts b/frontend/packages/console-app/src/components/nodes/utils/utils.ts new file mode 100644 index 00000000000..71f427ff819 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/utils/utils.ts @@ -0,0 +1,56 @@ +export const formatDurationForDisplay = (value: string | undefined): string | undefined => { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + return /[smhd]$/i.test(trimmed) ? trimmed : `${trimmed}s`; +}; + +export const parseDurationToSeconds = (raw: string | undefined): number | undefined => { + const value = raw?.trim(); + if (!value) { + return undefined; + } + const match = value.match(/^(\d+)([smhd]?)$/i); + if (!match) { + return undefined; + } + const amount = Number(match[1]); + const unit = (match[2] || 's').toLowerCase(); + switch (unit) { + case 'm': + return amount * 60; + case 'h': + return amount * 3600; + case 'd': + return amount * 86400; + case 's': + default: + return amount; + } +}; + +export const formatTimeoutForDisplay = (seconds: number): string => { + if (seconds % 60 === 0) { + return `${seconds / 60}m`; + } + return `${seconds}s`; +}; + +export const getMaxTimeoutFromConditions = ( + conditions: { timeout?: string; duration?: string }[], +): number | undefined => + conditions.reduce((maxValue, condition) => { + const rawTimeout = 'timeout' in condition ? condition.timeout : condition.duration; + const timeoutInSeconds = parseDurationToSeconds(rawTimeout); + + if (timeoutInSeconds === undefined) { + return maxValue; + } + + if (maxValue === undefined || timeoutInSeconds > maxValue) { + return timeoutInSeconds; + } + + return maxValue; + }, undefined); From 9569949f8964ec71874dac0f2e22b9261a737f29 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Date: Mon, 2 Mar 2026 17:16:26 -0500 Subject: [PATCH 4/5] CONSOLE-4954: Add Workload tab to Node view --- .../console-app/locales/en/console-app.json | 1 + .../src/components/nodes/NodeDetailsPage.tsx | 12 +++++-- .../src/components/nodes/NodeWorkload.tsx | 31 +++++++++++++++++++ frontend/public/components/pod-list.tsx | 7 ++++- 4 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 frontend/packages/console-app/src/components/nodes/NodeWorkload.tsx diff --git a/frontend/packages/console-app/locales/en/console-app.json b/frontend/packages/console-app/locales/en/console-app.json index 3f4892e37c1..0771904770d 100644 --- a/frontend/packages/console-app/locales/en/console-app.json +++ b/frontend/packages/console-app/locales/en/console-app.json @@ -526,6 +526,7 @@ "Kubelet version": "Kubelet version", "Kube-Proxy version": "Kube-Proxy version", "Configuration": "Configuration", + "Workload": "Workload", "Machine set": "Machine set", "This count is based on your access permissions and might not include all virtual machines.": "This count is based on your access permissions and might not include all virtual machines.", "{{formattedCores}} cores / {{totalCores}} cores": "{{formattedCores}} cores / {{totalCores}} cores", diff --git a/frontend/packages/console-app/src/components/nodes/NodeDetailsPage.tsx b/frontend/packages/console-app/src/components/nodes/NodeDetailsPage.tsx index ed087856fea..ff674093da1 100644 --- a/frontend/packages/console-app/src/components/nodes/NodeDetailsPage.tsx +++ b/frontend/packages/console-app/src/components/nodes/NodeDetailsPage.tsx @@ -21,6 +21,7 @@ import NodeDashboard from './node-dashboard/NodeDashboard'; import NodeDetails from './NodeDetails'; import NodeLogs from './NodeLogs'; import NodeTerminal from './NodeTerminal'; +import { NodeWorkload } from './NodeWorkload'; const NodePodsPage: FC> = ({ obj }) => ( > = (props) = nameKey: 'console-app~Configuration', component: NodeConfiguration, }, + { + href: 'workload', + // t('console-app~Workload') + nameKey: 'console-app~Workload', + component: NodeWorkload, + }, + navFactory.editYaml(), ] - : []), - navFactory.editYaml(), - navFactory.pods(NodePodsPage), + : [navFactory.editYaml(), navFactory.pods(NodePodsPage)]), navFactory.logs(NodeLogs), navFactory.events(ResourceEventStream), ...(!isWindowsNode(node) ? [navFactory.terminal(NodeTerminal)] : []), diff --git a/frontend/packages/console-app/src/components/nodes/NodeWorkload.tsx b/frontend/packages/console-app/src/components/nodes/NodeWorkload.tsx new file mode 100644 index 00000000000..dc21ad4e812 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/NodeWorkload.tsx @@ -0,0 +1,31 @@ +import type { FC } from 'react'; +import type { NodeKind } from '@console/dynamic-plugin-sdk/src'; +import { PodsPage } from '@console/internal/components/pod-list'; +import type { PageComponentProps } from '@console/internal/components/utils'; +import { NodeSubNavPage } from './NodeSubNavPage'; + +const NodePodsPage: FC> = ({ obj }) => ( + +); + +type NodeWorkloadProps = { + obj: NodeKind; +}; + +const standardPages = [ + { + tabId: 'pods', + // t('console-app~Pods') + nameKey: 'console-app~Pods', + component: NodePodsPage, + priority: 30, + }, +]; + +export const NodeWorkload: FC = ({ obj }) => ( + +); diff --git a/frontend/public/components/pod-list.tsx b/frontend/public/components/pod-list.tsx index ddff0425213..23f4a97a658 100644 --- a/frontend/public/components/pod-list.tsx +++ b/frontend/public/components/pod-list.tsx @@ -588,6 +588,7 @@ export const PodsPage: FC = ({ hideLabelFilter, hideColumnManagement, showNamespaceOverride, + hideFavoriteButton, }) => { const { t } = useTranslation(); const dispatch = useConsoleDispatch(); @@ -638,7 +639,10 @@ export const PodsPage: FC = ({ return ( <> - + {canCreate && ( {t('public~Create Pod')} @@ -711,4 +715,5 @@ type PodPageProps = { hideNameLabelFilters?: boolean; hideColumnManagement?: boolean; showNamespaceOverride?: boolean; + hideFavoriteButton?: boolean; }; From 128170f3cf430a1c53723435582b879cf41f18fd Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Date: Wed, 11 Mar 2026 13:26:12 -0400 Subject: [PATCH 5/5] CONSOLE-4951: Add Health tab to Node View, remove events tab --- .../console-app/locales/en/console-app.json | 17 +- .../src/components/nodes/NodeDetailsPage.tsx | 16 +- .../src/components/nodes/NodeLogs.tsx | 5 + .../components/nodes/health/NodeHealth.tsx | 30 +++ .../nodes/health/NodePerformance.tsx | 194 ++++++++++++++++++ 5 files changed, 257 insertions(+), 5 deletions(-) create mode 100644 frontend/packages/console-app/src/components/nodes/health/NodeHealth.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/health/NodePerformance.tsx diff --git a/frontend/packages/console-app/locales/en/console-app.json b/frontend/packages/console-app/locales/en/console-app.json index 0771904770d..4d78c2ddad3 100644 --- a/frontend/packages/console-app/locales/en/console-app.json +++ b/frontend/packages/console-app/locales/en/console-app.json @@ -444,6 +444,20 @@ "MachineConfig selector": "MachineConfig selector", "Current configuration": "Current configuration", "Current configuration source": "Current configuration source", + "Performance": "Performance", + "Logs": "Logs", + "CPU": "CPU", + "CPU Utilisation": "CPU Utilisation", + "CPU Saturation (Load per CPU)": "CPU Saturation (Load per CPU)", + "Memory": "Memory", + "Memory Utilisation": "Memory Utilisation", + "Memory Saturation (Major Page Faults)": "Memory Saturation (Major Page Faults)", + "Network": "Network", + "Network Utilisation (Bytes Receive/Transmit)": "Network Utilisation (Bytes Receive/Transmit)", + "Network Saturation (Drops Receive/Transmit)": "Network Saturation (Drops Receive/Transmit)", + "Disk IO": "Disk IO", + "Disk IO Utilisation": "Disk IO Utilisation", + "Disk IO Saturation": "Disk IO Saturation", "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", @@ -473,7 +487,6 @@ "Activity": "Activity", "Disk": "Disk", "Network interface": "Network interface", - "CPU": "CPU", "Node name": "Node name", "Not available": "Not available", "Node addresses": "Node addresses", @@ -496,7 +509,6 @@ "Only one {{ machineHealthCheckLabel }} resource should match this node.": "Only one {{ machineHealthCheckLabel }} resource should match this node.", "Not configured": "Not configured", "No conditions": "No conditions", - "Memory": "Memory", "Network in": "Network in", "Network out": "Network out", "Utilization": "Utilization", @@ -526,6 +538,7 @@ "Kubelet version": "Kubelet version", "Kube-Proxy version": "Kube-Proxy version", "Configuration": "Configuration", + "Health": "Health", "Workload": "Workload", "Machine set": "Machine set", "This count is based on your access permissions and might not include all virtual machines.": "This count is based on your access permissions and might not include all virtual machines.", diff --git a/frontend/packages/console-app/src/components/nodes/NodeDetailsPage.tsx b/frontend/packages/console-app/src/components/nodes/NodeDetailsPage.tsx index ff674093da1..7ae2b040e2a 100644 --- a/frontend/packages/console-app/src/components/nodes/NodeDetailsPage.tsx +++ b/frontend/packages/console-app/src/components/nodes/NodeDetailsPage.tsx @@ -17,6 +17,7 @@ import { useFlag } from '@console/shared/src/hooks/useFlag'; import { isWindowsNode } from '@console/shared/src/selectors/node'; import { nodeStatus } from '../../status/node'; import { NodeConfiguration } from './configuration/NodeConfiguration'; +import { NodeHealth } from './health/NodeHealth'; import NodeDashboard from './node-dashboard/NodeDashboard'; import NodeDetails from './NodeDetails'; import NodeLogs from './NodeLogs'; @@ -56,6 +57,12 @@ export const NodeDetailsPage: FC> = (props) = nameKey: 'console-app~Configuration', component: NodeConfiguration, }, + { + href: 'health', + // t('console-app~Health') + nameKey: 'console-app~Health', + component: NodeHealth, + }, { href: 'workload', // t('console-app~Workload') @@ -64,9 +71,12 @@ export const NodeDetailsPage: FC> = (props) = }, navFactory.editYaml(), ] - : [navFactory.editYaml(), navFactory.pods(NodePodsPage)]), - navFactory.logs(NodeLogs), - navFactory.events(ResourceEventStream), + : [ + navFactory.editYaml(), + navFactory.pods(NodePodsPage), + navFactory.logs(NodeLogs), + navFactory.events(ResourceEventStream), + ]), ...(!isWindowsNode(node) ? [navFactory.terminal(NodeTerminal)] : []), ], [nodeMgmtV1Enabled], diff --git a/frontend/packages/console-app/src/components/nodes/NodeLogs.tsx b/frontend/packages/console-app/src/components/nodes/NodeLogs.tsx index bc7244b23b0..01997801b14 100644 --- a/frontend/packages/console-app/src/components/nodes/NodeLogs.tsx +++ b/frontend/packages/console-app/src/components/nodes/NodeLogs.tsx @@ -19,8 +19,11 @@ import { import { LogViewer, LogViewerSearch } from '@patternfly/react-log-viewer'; import { css } from '@patternfly/react-styles'; import { Trans, useTranslation } from 'react-i18next'; +import { FLAG_NODE_MGMT_V1 } from '@console/app/src/consts'; +import { useFlag } from '@console/dynamic-plugin-sdk/src/utils/flags'; import { coFetch } from '@console/internal/co-fetch'; import { useTheme } from '@console/internal/components/ThemeProvider'; +import { SectionHeading } from '@console/internal/components/utils'; import { LoadingBox, LoadingInline } from '@console/internal/components/utils/status-box'; import type { NodeKind } from '@console/internal/module/k8s'; import { modelFor, resourceURL } from '@console/internal/module/k8s'; @@ -176,6 +179,7 @@ const HeaderBanner: FC<{ lineCount: number }> = ({ lineCount }) => { const NodeLogs: FC = ({ obj: node }) => { const { getQueryArgument, setQueryArgument, removeQueryArgument } = useQueryParamsMutator(); + const nodeMgmtV1Enabled = useFlag(FLAG_NODE_MGMT_V1); const { kind, @@ -369,6 +373,7 @@ const NodeLogs: FC = ({ obj: node }) => { return ( + {nodeMgmtV1Enabled ? : null}
{(isLoadingLog || errorExists) && logControls} {(lineCount >= MAX_LINE_COUNT || trimmedContent?.length > 0) && !isLoadingLog && ( diff --git a/frontend/packages/console-app/src/components/nodes/health/NodeHealth.tsx b/frontend/packages/console-app/src/components/nodes/health/NodeHealth.tsx new file mode 100644 index 00000000000..b668a3333ca --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/health/NodeHealth.tsx @@ -0,0 +1,30 @@ +import type { FC } from 'react'; +import type { NodeKind } from '@console/dynamic-plugin-sdk/src'; +import NodeLogs from '../NodeLogs'; +import { NodeSubNavPage } from '../NodeSubNavPage'; +import NodePerformance from './NodePerformance'; + +type NodeHealthProps = { + obj: NodeKind; +}; + +const standardPages = [ + { + tabId: 'performance', + // t('console-app~Performance') + nameKey: 'console-app~Performance', + component: NodePerformance, + priority: 70, + }, + { + tabId: 'logs', + // t('console-app~Logs') + nameKey: 'console-app~Logs', + component: NodeLogs, + priority: 30, + }, +]; + +export const NodeHealth: FC = ({ obj }) => ( + +); diff --git a/frontend/packages/console-app/src/components/nodes/health/NodePerformance.tsx b/frontend/packages/console-app/src/components/nodes/health/NodePerformance.tsx new file mode 100644 index 00000000000..6b2a1d7ed42 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/health/NodePerformance.tsx @@ -0,0 +1,194 @@ +import type { FC } from 'react'; +import { useState, useMemo } from 'react'; +import { + Card, + CardBody, + CardHeader, + CardTitle, + Flex, + FlexItem, + Grid, + GridItem, + ExpandableSectionToggle, +} from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import type { NodeKind } from '@console/dynamic-plugin-sdk'; +import { SectionHeading } from '@console/internal/components/utils'; +import PaneBody from '@console/shared/src/components/layout/PaneBody'; +import { QueryBrowser } from '@console/shared/src/components/query-browser'; + +type NodePerformanceProps = { + obj: NodeKind; +}; + +type ChartConfig = { + title: string; + queries: string[]; + units?: string; + isStack?: boolean; +}; + +type RowConfig = { + title: string; + charts: ChartConfig[]; +}; + +const PerformanceChart: FC<{ config: ChartConfig }> = ({ config }) => ( + + + {config.title} + + + + + +); + +type PerformanceRowProps = { + row: RowConfig; + defaultExpanded?: boolean; +}; + +const PerformanceRow: FC = ({ row, defaultExpanded = true }) => { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + + return ( + + + setIsExpanded((prev) => !prev)} + > + {row.title} + + + {isExpanded && ( + + + {row.charts.map((chart) => ( + + + + ))} + + + )} + + ); +}; + +const NodePerformance: FC = ({ obj }) => { + const { t } = useTranslation(); + const nodeName = obj?.metadata?.name; + + const rows: RowConfig[] = useMemo( + () => [ + { + title: t('console-app~CPU'), + charts: [ + { + title: t('console-app~CPU Utilisation'), + queries: [`instance:node_cpu:rate:sum{instance='${nodeName}'}`], + units: 'cores', + isStack: true, + }, + { + title: t('console-app~CPU Saturation (Load per CPU)'), + queries: [ + `node_load1{instance='${nodeName}'} / instance:node_num_cpu:sum{instance='${nodeName}'}`, + ], + isStack: true, + }, + ], + }, + { + title: t('console-app~Memory'), + charts: [ + { + title: t('console-app~Memory Utilisation'), + queries: [ + `node_memory_MemTotal_bytes{instance='${nodeName}'} - node_memory_MemAvailable_bytes{instance='${nodeName}'}`, + ], + units: 'bytes', + isStack: true, + }, + { + title: t('console-app~Memory Saturation (Major Page Faults)'), + queries: [`rate(node_vmstat_pgmajfault{instance='${nodeName}'}[5m])`], + isStack: true, + }, + ], + }, + { + title: t('console-app~Network'), + charts: [ + { + title: t('console-app~Network Utilisation (Bytes Receive/Transmit)'), + queries: [ + `instance:node_network_receive_bytes:rate:sum{instance='${nodeName}'}`, + `instance:node_network_transmit_bytes:rate:sum{instance='${nodeName}'}`, + ], + units: 'Bps', + isStack: true, + }, + { + title: t('console-app~Network Saturation (Drops Receive/Transmit)'), + queries: [ + `sum(rate(node_network_receive_drop_total{instance='${nodeName}'}[5m]))`, + `sum(rate(node_network_transmit_drop_total{instance='${nodeName}'}[5m]))`, + ], + isStack: true, + }, + ], + }, + { + title: t('console-app~Disk IO'), + charts: [ + { + title: t('console-app~Disk IO Utilisation'), + queries: [ + `sum(rate(node_disk_read_bytes_total{instance='${nodeName}'}[5m]))`, + `sum(rate(node_disk_written_bytes_total{instance='${nodeName}'}[5m]))`, + ], + units: 'Bps', + isStack: true, + }, + { + title: t('console-app~Disk IO Saturation'), + queries: [ + `sum(rate(node_disk_read_time_seconds_total{instance='${nodeName}'}[5m]))`, + `sum(rate(node_disk_write_time_seconds_total{instance='${nodeName}'}[5m]))`, + ], + isStack: true, + }, + ], + }, + ], + [nodeName, t], + ); + + return ( + + + + {rows.map((row) => ( + + + + ))} + + + ); +}; + +export default NodePerformance;