From 0b961a010f83f3bf30fbd10840e952aeb690a60f Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Date: Tue, 13 Jan 2026 11:53:04 -0500 Subject: [PATCH] CONSOLE-4943: Add Machine Set, MachineConfigPool, and VMs columns to Nodes list view --- .../console-app/locales/en/console-app.json | 6 + .../src/components/nodes/NodesPage.tsx | 325 ++++++++++++++++-- frontend/public/actions/ui.ts | 1 + 3 files changed, 304 insertions(+), 28 deletions(-) diff --git a/frontend/packages/console-app/locales/en/console-app.json b/frontend/packages/console-app/locales/en/console-app.json index 1c3f65e0fa9..342bf2b6df8 100644 --- a/frontend/packages/console-app/locales/en/console-app.json +++ b/frontend/packages/console-app/locales/en/console-app.json @@ -406,6 +406,10 @@ "Container runtime": "Container runtime", "Kubelet version": "Kubelet version", "Kube-Proxy version": "Kube-Proxy version", + "Machine set": "Machine set", + "Virtual machines": "Virtual machines", + "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", @@ -416,6 +420,8 @@ "Filter by status": "Filter by status", "Filter by roles": "Filter by roles", "Filter by architecture": "Filter by architecture", + "Filter by machine set": "Filter by machine set", + "Filter by MachineConfigPool": "Filter by MachineConfigPool", "Node status": "Node status", "This node's {{conditionDescription}}. Performance may be degraded.": "This node's {{conditionDescription}}. Performance may be degraded.", "<0>To use host binaries, run <1>chroot /host": "<0>To use host binaries, run <1>chroot /host", diff --git a/frontend/packages/console-app/src/components/nodes/NodesPage.tsx b/frontend/packages/console-app/src/components/nodes/NodesPage.tsx index dfa66b322f1..f0cc400a274 100644 --- a/frontend/packages/console-app/src/components/nodes/NodesPage.tsx +++ b/frontend/packages/console-app/src/components/nodes/NodesPage.tsx @@ -18,11 +18,18 @@ import { ResourceFilters, } from '@console/app/src/components/data-view/types'; import { + getGroupVersionKindForResource, + K8sModel, ListPageBody, useAccessReview, + useFlag, } from '@console/dynamic-plugin-sdk/src/api/dynamic-core-api'; import { + K8sGroupVersionKind, + K8sResourceCommon, + K8sResourceKind, NodeCertificateSigningRequestKind, + OwnerReference, RowProps, TableColumn, } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; @@ -36,13 +43,25 @@ import { LabelList } from '@console/internal/components/utils/label-list'; import { ResourceLink } from '@console/internal/components/utils/resource-link'; import { LoadingBox } from '@console/internal/components/utils/status-box'; import { humanizeBinaryBytes, formatCores } from '@console/internal/components/utils/units'; -import { NodeModel, MachineModel } from '@console/internal/models'; +import { + NodeModel, + MachineModel, + MachineConfigPoolModel, + MachineSetModel, + ControlPlaneMachineSetModel, + CertificateSigningRequestModel, +} from '@console/internal/models'; import { NodeKind, referenceForModel, CertificateSigningRequestKind, referenceFor, Selector, + MachineKind, + MachineConfigPoolKind, + LabelSelector, + MachineSetKind, + ControlPlaneMachineSetKind, } from '@console/internal/module/k8s'; import { RootState } from '@console/internal/redux'; import LazyActionMenu from '@console/shared/src/components/actions/LazyActionMenu'; @@ -82,6 +101,13 @@ import { NodeStatusWithExtensions } from './NodeStatus'; import ClientCSRStatus from './status/CSRStatus'; import { GetNodeStatusExtensions, useNodeStatusExtensions } from './useNodeStatusExtensions'; +// TODO: Remove VMI retrieval and VMs count column if/when the plugin is able to add the VMs count column +const VirtualMachineInstanceGroupVersionKind: K8sGroupVersionKind = { + group: 'kubevirt.io', + kind: 'VirtualMachineInstance', + version: 'v1', +}; + const nodeColumnInfo = Object.freeze({ name: { id: 'name', @@ -89,8 +115,11 @@ const nodeColumnInfo = Object.freeze({ status: { id: 'status', }, - role: { - id: 'role', + machineOwner: { + id: 'machineOwner', + }, + vms: { + id: 'vms', }, pods: { id: 'pods', @@ -101,6 +130,9 @@ const nodeColumnInfo = Object.freeze({ cpu: { id: 'cpu', }, + role: { + id: 'role', + }, architecture: { id: 'architecture', }, @@ -116,6 +148,9 @@ const nodeColumnInfo = Object.freeze({ machine: { id: 'machine', }, + machineConfigPool: { + id: 'machineConfigPool', + }, labels: { id: 'labels', }, @@ -132,7 +167,7 @@ const nodeColumnInfo = Object.freeze({ const kind = 'Node'; -const useNodesColumns = (): TableColumn[] => { +const useNodesColumns = (vmsEnabled: boolean): TableColumn[] => { const { t } = useTranslation(); const columns = useMemo(() => { return [ @@ -154,13 +189,33 @@ const useNodesColumns = (): TableColumn[] => { }, }, { - title: t('console-app~Roles'), - id: nodeColumnInfo.role.id, - sort: sortWithCSRResource(nodeRolesSort, ''), + title: t('console-app~Machine set'), + id: nodeColumnInfo.machineOwner.id, + sort: 'machineOwner.name', props: { modifier: 'nowrap', }, }, + ...(vmsEnabled + ? [ + { + title: t('console-app~Virtual machines'), + id: nodeColumnInfo.vms.id, + sort: 'virtualMachines', + props: { + modifier: 'nowrap', + info: { + tooltip: t( + 'console-app~This count is based on your access permissions and might not include all virtual machines.', + ), + tooltipProps: { + isContentLeftAligned: true, + }, + }, + }, + }, + ] + : []), { title: t('console-app~Pods'), id: nodeColumnInfo.pods.id, @@ -185,6 +240,15 @@ const useNodesColumns = (): TableColumn[] => { modifier: 'nowrap', }, }, + { + title: t('console-app~Roles'), + id: nodeColumnInfo.role.id, + sort: sortWithCSRResource(nodeRolesSort, ''), + props: { + modifier: 'nowrap', + }, + additional: true, + }, { title: t('console-app~Architecture'), id: nodeColumnInfo.architecture.id, @@ -201,6 +265,7 @@ const useNodesColumns = (): TableColumn[] => { props: { modifier: 'nowrap', }, + additional: true, }, { title: t('console-app~Created'), @@ -209,6 +274,7 @@ const useNodesColumns = (): TableColumn[] => { props: { modifier: 'nowrap', }, + additional: true, }, { title: t('console-app~Instance type'), @@ -217,6 +283,7 @@ const useNodesColumns = (): TableColumn[] => { props: { modifier: 'nowrap', }, + additional: true, }, { title: t('console-app~Machine'), @@ -227,6 +294,15 @@ const useNodesColumns = (): TableColumn[] => { }, additional: true, }, + { + title: t('console-app~MachineConfigPool'), + id: nodeColumnInfo.machineConfigPool.id, + sort: 'machineConfigPool.metadata.name', + props: { + modifier: 'nowrap', + }, + additional: true, + }, { title: t('console-app~Labels'), id: nodeColumnInfo.labels.id, @@ -263,7 +339,7 @@ const useNodesColumns = (): TableColumn[] => { }, }, ]; - }, [t]); + }, [t, vmsEnabled]); return columns; }; @@ -309,6 +385,7 @@ const getNodeDataViewRows = ( const pods = nodeMetrics?.pods?.[nodeName] ?? DASH; const architecture = node ? getNodeArchitecture(node) : ''; const [machineName, machineNamespace] = node ? getNodeMachineNameAndNamespace(node) : ['', '']; + const { machineOwner, machineConfigPool, virtualMachines } = obj; const instanceType = node?.metadata.labels?.['beta.kubernetes.io/instance-type'] || ''; const labels = node ? getLabels(node) : csr ? getLabels(csr) : {}; const zone = node?.metadata.labels?.['topology.kubernetes.io/zone'] || ''; @@ -385,6 +462,31 @@ const getNodeDataViewRows = ( DASH ), }, + [nodeColumnInfo.machineOwner.id]: { + cell: + machineOwner && machineNamespace ? ( + + ) : ( + DASH + ), + }, + [nodeColumnInfo.machineConfigPool.id]: { + cell: machineConfigPool ? ( + + ) : ( + DASH + ), + }, + [nodeColumnInfo.vms.id]: { + cell: Number.isNaN(virtualMachines) ? DASH : String(virtualMachines), + }, [nodeColumnInfo.labels.id]: { cell: , }, @@ -464,6 +566,10 @@ type NodeListProps = { data: NodeRowItem[]; loaded: boolean; loadError?: unknown; + machineSets?: MachineSetKind[]; + controlPlaneMachineSets?: ControlPlaneMachineSetKind[]; + machineConfigPools?: MachineConfigPoolKind[]; + vmsEnabled: boolean; hideNameLabelFilters?: boolean; hideLabelFilter?: boolean; hideColumnManagement?: boolean; @@ -474,13 +580,17 @@ const NodeList: FC = ({ data, loaded, loadError, + machineSets = [], + controlPlaneMachineSets = [], + machineConfigPools = [], + vmsEnabled, hideNameLabelFilters, hideLabelFilter, hideColumnManagement, selectedColumns, }) => { const { t } = useTranslation(); - const columns = useNodesColumns(); + const columns = useNodesColumns(vmsEnabled); const nodeMetrics = useSelector(({ UI }) => { return UI.getIn(['metrics', 'node']); }); @@ -546,8 +656,41 @@ const NodeList: FC = ({ [], ); + const machineSetFilterOptions = useMemo( + () => + [ + ...machineSets.map((machineSet) => ({ + value: machineSet.metadata.name, + label: machineSet.metadata.name, + })), + ...controlPlaneMachineSets.map((machineSet) => ({ + value: machineSet.metadata.name, + label: machineSet.metadata.name, + })), + ].sort((a, b) => a.label.localeCompare(b.label)), + [machineSets, controlPlaneMachineSets], + ); + + const machineConfigPoolFilterOptions = useMemo( + () => + machineConfigPools + .map((machineConfigPool) => ({ + value: machineConfigPool.metadata.name, + label: machineConfigPool.metadata.name, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + [machineConfigPools], + ); + const initialFilters = useMemo( - () => ({ ...initialFiltersDefault, status: [], roles: [], architecture: [] }), + () => ({ + ...initialFiltersDefault, + status: [], + roles: [], + architecture: [], + machineOwners: [], + machineConfigPools: [], + }), [], ); @@ -575,8 +718,29 @@ const NodeList: FC = ({ placeholder={t('console-app~Filter by architecture')} options={nodeArchitectureFilterOptions} />, + , + , + ], + [ + t, + nodeStatusFilterOptions, + nodeRoleFilterOptions, + nodeArchitectureFilterOptions, + machineSetFilterOptions, + machineConfigPoolFilterOptions, ], - [t, nodeStatusFilterOptions, nodeRoleFilterOptions, nodeArchitectureFilterOptions], ); const matchesAdditionalFilters = useCallback((resource: NodeRowItem, filters: NodeFilters) => { @@ -612,6 +776,21 @@ const NodeList: FC = ({ } } + if (filters.machineOwners.length > 0) { + if (!resource.machineOwner || !filters.machineOwners.includes(resource.machineOwner.name)) { + return false; + } + } + + if (filters.machineConfigPools.length > 0) { + if ( + !resource.machineConfigPool || + !filters.machineConfigPools.includes(resource.machineConfigPool.metadata.name) + ) { + return false; + } + } + return true; }, []); @@ -644,39 +823,43 @@ const NodeList: FC = ({ ); }; -type NodeRowItem = NodeKind | NodeCertificateSigningRequestKind; +type NodeRowItem = (NodeKind | NodeCertificateSigningRequestKind) & { + machineOwner?: OwnerReference; + machineConfigPool?: MachineConfigPoolKind; + virtualMachines?: number; +}; type NodeFilters = ResourceFilters & { status: string[]; roles: string[]; architecture: string[]; + machineOwners: string[]; + machineConfigPools: string[]; }; -const useWatchCSRs = (): [CertificateSigningRequestKind[], boolean, unknown] => { +const useWatchResourcesIfAllowed = ( + model: K8sModel, +): [R, boolean, unknown] => { const [isAllowed, checkIsLoading] = useAccessReview({ - group: 'certificates.k8s.io', - resource: 'CertificateSigningRequest', + group: model.apiGroup || '', + resource: model.plural, verb: 'list', }); - - const [csrs, loaded, error] = useK8sWatchResource( + const [resources, loaded, loadError] = useK8sWatchResource( isAllowed ? { - groupVersionKind: { - group: 'certificates.k8s.io', - kind: 'CertificateSigningRequest', - version: 'v1', - }, + kind: referenceForModel(model), isList: true, } : undefined, ); - return [csrs, !checkIsLoading && loaded, error]; + return [resources || ([] as R), !checkIsLoading && loaded, loadError]; }; export const NodesPage: FC = ({ selector }) => { const dispatch = useDispatch(); + const { t } = useTranslation(); const [selectedColumns, , userSettingsLoaded] = useUserSettingsCompatibility( COLUMN_MANAGEMENT_CONFIGMAP_KEY, @@ -694,7 +877,56 @@ export const NodesPage: FC = ({ selector }) => { selector, }); - const [csrs, csrsLoaded, csrsLoadError] = useWatchCSRs(); + const [machines, machinesLoaded] = useWatchResourcesIfAllowed(MachineModel); + + const machinesByName = useMemo( + () => new Map(machines.map((m) => [`${m.metadata.name}/${m.metadata.namespace}`, m])), + [machines], + ); + + const [machineSets, machineSetsLoaded] = useWatchResourcesIfAllowed( + MachineSetModel, + ); + + const [controlPlaneMachineSets, controlPlaneMachineSetsLoaded] = useWatchResourcesIfAllowed< + ControlPlaneMachineSetKind[] + >(ControlPlaneMachineSetModel); + + const [machineConfigPools, machineConfigPoolsLoaded] = useWatchResourcesIfAllowed< + MachineConfigPoolKind[] + >(MachineConfigPoolModel); + + const [csrs, csrsLoaded, csrsLoadError] = useWatchResourcesIfAllowed< + CertificateSigningRequestKind[] + >(CertificateSigningRequestModel); + + const kubevirtFeature = useFlag('KUBEVIRT_DYNAMIC'); + const isKubevirtPluginActive = + Array.isArray(window.SERVER_FLAGS.consolePlugins) && + window.SERVER_FLAGS.consolePlugins.includes('kubevirt-plugin') && + kubevirtFeature; + + const [vmis, vmisLoaded, vmisLoadError] = useK8sWatchResource( + isKubevirtPluginActive + ? { + isList: true, + groupVersionKind: VirtualMachineInstanceGroupVersionKind, + } + : undefined, + ); + + const vmsByNode = useMemo(() => { + if (!isKubevirtPluginActive || !nodesLoaded || nodesLoadError || !vmisLoaded || vmisLoadError) { + return undefined; + } + + return new Map( + nodes.map((node) => [ + `${node.metadata.name}`, + vmis.filter((vm) => vm.status?.nodeName === node.metadata.name), + ]), + ); + }, [isKubevirtPluginActive, nodes, nodesLoadError, nodesLoaded, vmis, vmisLoadError, vmisLoaded]); useEffect(() => { const updateMetrics = async () => { @@ -713,16 +945,49 @@ export const NodesPage: FC = ({ selector }) => { } return () => {}; }, [dispatch]); - const { t } = useTranslation(); const data = useMemo(() => { const csrBundle = getNodeClientCSRs(csrs).filter( (csr) => !nodes.some((n) => n.metadata.name === csr.metadata.name), ); - return [...csrBundle, ...nodes]; - }, [csrs, nodes]); - const loaded = nodesLoaded && csrsLoaded; + return [ + ...csrBundle, + ...nodes.map((node) => { + const [machineName, machineNamespace] = node + ? getNodeMachineNameAndNamespace(node) + : ['', '']; + + const owner = machinesByName.get(`${machineName}/${machineNamespace}`); + + const machineConfigPool = machineConfigPools.find((mcp) => { + const labelSelector = new LabelSelector(mcp.spec.nodeSelector || {}); + return labelSelector.matches(node); + }); + + const machineOwner = owner?.metadata.ownerReferences?.find( + (ref) => + ref.kind === MachineSetModel.kind || ref.kind === ControlPlaneMachineSetModel.kind, + ); + + return { + ...node, + machineOwner, + machineConfigPool, + virtualMachines: vmsByNode?.get(node.metadata.name)?.length, + }; + }), + ]; + }, [csrs, nodes, machinesByName, machineConfigPools, vmsByNode]); + + const loaded = + nodesLoaded && + csrsLoaded && + machinesLoaded && + machineSetsLoaded && + controlPlaneMachineSetsLoaded && + machineConfigPoolsLoaded; + // Don't fail on machine load errors, instead we hide those columns and filters const loadError = nodesLoadError || csrsLoadError; if (!userSettingsLoaded) { @@ -737,6 +1002,10 @@ export const NodesPage: FC = ({ selector }) => { data={data} loaded={loaded} loadError={loadError} + machineSets={machineSets} + controlPlaneMachineSets={controlPlaneMachineSets} + machineConfigPools={machineConfigPools} + vmsEnabled={isKubevirtPluginActive} selectedColumns={selectedColumns} /> diff --git a/frontend/public/actions/ui.ts b/frontend/public/actions/ui.ts index 5862d572462..e64ea477fc7 100644 --- a/frontend/public/actions/ui.ts +++ b/frontend/public/actions/ui.ts @@ -48,6 +48,7 @@ export type NodeMetrics = { totalMemory: MetricValuesByName; usedStorage: MetricValuesByName; totalStorage: MetricValuesByName; + runningVms: MetricValuesByName; }; export type PVCMetrics = {