diff --git a/frontend/packages/console-app/locales/en/console-app.json b/frontend/packages/console-app/locales/en/console-app.json index ce594b3c6a8..4d78c2ddad3 100644 --- a/frontend/packages/console-app/locales/en/console-app.json +++ b/frontend/packages/console-app/locales/en/console-app.json @@ -352,6 +352,112 @@ "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", + "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", + "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", + "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", + "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", + "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", @@ -381,10 +487,7 @@ "Activity": "Activity", "Disk": "Disk", "Network interface": "Network interface", - "CPU": "CPU", - "Details": "Details", "Node name": "Node name", - "Instance type": "Instance type", "Not available": "Not available", "Node addresses": "Node addresses", "Uptime": "Uptime", @@ -406,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", @@ -425,11 +527,9 @@ "Annotations": "Annotations", "Annotation_one": "Annotation", "Annotation_other": "Annotations", - "Machine": "Machine", "Provider ID": "Provider ID", "Unschedulable": "Unschedulable", "Created": "Created", - "Operating system": "Operating system", "OS image": "OS image", "Architecture": "Architecture", "Kernel version": "Kernel version", @@ -437,12 +537,13 @@ "Container runtime": "Container runtime", "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.", - "MachineConfigPool": "MachineConfigPool", "{{formattedCores}} cores / {{totalCores}} cores": "{{formattedCores}} cores / {{totalCores}} cores", "Node": "Node", - "Ready": "Ready", "Not Ready": "Not Ready", "Discovered": "Discovered", "control-plane": "control-plane", @@ -465,6 +566,17 @@ "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", "Remove identity provider": "Remove identity provider", @@ -508,7 +620,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/NodeDetailsPage.tsx b/frontend/packages/console-app/src/components/nodes/NodeDetailsPage.tsx index 0ef0cfbfd74..7ae2b040e2a 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,12 +13,16 @@ 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 { NodeHealth } from './health/NodeHealth'; 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 }) => ( > = ({ obj }) => ( ); export const NodeDetailsPage: FC> = (props) => { + const nodeMgmtV1Enabled = useFlag(FLAG_NODE_MGMT_V1); + const pagesFor = useCallback( (node: NodeKind) => [ { @@ -42,13 +49,37 @@ export const NodeDetailsPage: FC> = (props) = nameKey: 'console-app~Details', component: NodeDetails, }, - navFactory.editYaml(), - navFactory.pods(NodePodsPage), - navFactory.logs(NodeLogs), - navFactory.events(ResourceEventStream), + ...(nodeMgmtV1Enabled + ? [ + { + href: 'configuration', + // t('console-app~Configuration') + nameKey: 'console-app~Configuration', + component: NodeConfiguration, + }, + { + href: 'health', + // t('console-app~Health') + nameKey: 'console-app~Health', + component: NodeHealth, + }, + { + href: 'workload', + // t('console-app~Workload') + nameKey: 'console-app~Workload', + component: NodeWorkload, + }, + navFactory.editYaml(), + ] + : [ + 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/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/NodeSubNavPage.tsx b/frontend/packages/console-app/src/components/nodes/NodeSubNavPage.tsx new file mode 100644 index 00000000000..42281c36b7e --- /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/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/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..2689aab6998 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/NodeConfiguration.tsx @@ -0,0 +1,46 @@ +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'; + +type NodeConfigurationProps = { + obj: NodeKind; +}; + +const standardPages = [ + { + tabId: 'storage', + // t('console-app~Storage') + nameKey: 'console-app~Storage', + component: NodeStorage, + priority: 70, + }, + { + tabId: 'operating-system', + // t('console-app~Operating system') + nameKey: 'console-app~Operating system', + component: OperatingSystem, + priority: 50, + }, + { + tabId: 'machine', + // t('console-app~Machine') + nameKey: 'console-app~Machine', + 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/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 new file mode 100644 index 00000000000..17fe50bbc2e --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/__tests__/NodeConfiguration.spec.tsx @@ -0,0 +1,415 @@ +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('../machine/MachineDetails', () => ({ + __esModule: true, + default: jest.fn(() => null), +})); + +jest.mock('../machine/Machine', () => ({ + __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); + + renderWithProviders(); + + 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: 10, + }, + 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', + 'Operating system', + 'Machine', + 'High availability', + '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/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/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/machine/__tests__/NodeMachine.spec.tsx b/frontend/packages/console-app/src/components/nodes/configuration/machine/__tests__/NodeMachine.spec.tsx new file mode 100644 index 00000000000..6e6cb7dad4f --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/machine/__tests__/NodeMachine.spec.tsx @@ -0,0 +1,185 @@ +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 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'); + 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(), +})); + +jest.mock('../BMCConfiguration', () => ({ + __esModule: true, + default: () => null, +})); + +const getNodeMachineNameAndNamespaceMock = getNodeMachineNameAndNamespace as jest.Mock; +const useK8sWatchResourceMock = useK8sWatchResource as jest.Mock; + +describe('MachineDetails', () => { + 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']); + useK8sWatchResourceMock.mockReset(); + }); + + it('should display error message when machine config pool fails to load', () => { + useK8sWatchResourceMock.mockReturnValue([null, true, new Error('Failed to load')]); + + renderWithProviders(); + + 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.mockReturnValue([[], 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.mockReturnValue([[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.mockReturnValue([[pausedMCP], true, undefined]); + + 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(); + + 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..7806606b27f --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/node-storage/LocalDisks.tsx @@ -0,0 +1,101 @@ +import type { FC } from 'react'; +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'; +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 ? ( + + {bareMetalHostLoadError.message || t('console-app~Unable to load BareMetalHost')} + + ) : ( +
+ + + + + + + + + + + + + + {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..6e6d65a5315 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/configuration/node-storage/PersistentVolumes.tsx @@ -0,0 +1,332 @@ +import type { FC } from 'react'; +import { useMemo } from 'react'; +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'; +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, + namespaced: 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 || podsLoadError; + const isLoading = + !persistentVolumesLoaded || !pvcsLoaded || !dataVolumesLoaded || !vmsLoaded || !podsLoaded; + + const vmDataLoadError = vmsLoadError || dataVolumesLoadError; + const vmPVCs = useMemo(() => { + if (loadError || vmDataLoadError || isLoading) { + 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; + }, []) ?? [] + ); + }, [loadError, vmDataLoadError, isLoading, pvcs, persistentVolumes, dataVolumes, vms]); + + 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 ? ( + + {loadError.message ?? null} + + ) : nodePersistentVolumeData.length === 0 && vmDataLoadError ? ( + + {vmDataLoadError.message ?? null} + + ) : ( +
+ + + + + + + + + + + + + {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/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; 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/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/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/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/NodeBareMetalUtils.ts b/frontend/packages/console-app/src/components/nodes/utils/NodeBareMetalUtils.ts similarity index 67% 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..9edfdb2a5df 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,5 @@ +import { useTranslation } from 'react-i18next'; +import { useAccessibleResources } from '@console/app/src/components/nodes/utils/useAccessibleResources'; import type { K8sGroupVersionKind, K8sResourceKind, @@ -5,13 +7,15 @@ 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'; 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,28 +70,36 @@ 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] = 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, ); @@ -100,7 +112,7 @@ export const useWatchBareMetalHost = ( return [ bareMetalHost, bareMetalHostsLoaded && machinesLoaded, - bareMetalHostsLoadError || machinesLoadError, + bareMetalHostsLoadError || machinesLoadError || pluginError, ]; }; @@ -111,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/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 73% 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..4b43385f4d1 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,6 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useIsKubevirtPluginActive } from '@console/app/src/utils/kubevirt'; import type { K8sGroupVersionKind, K8sModel, @@ -5,9 +8,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 +24,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', @@ -37,25 +52,38 @@ 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, 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, + virtualMachineInstancesLoadError || pluginError, ]; }; 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/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/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-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); 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 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; };