From ee88cf55fb4c8cd9babaf7c59baefc3d6c104f74 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Date: Thu, 5 Feb 2026 13:23:02 -0500 Subject: [PATCH] CONSOLE-4945: Add Disk, Network, CPU and VM counts to node overview --- .../console-app/locales/en/console-app.json | 13 +- .../components/nodes/NodeBareMetalUtils.ts | 57 +++++++++ .../src/components/nodes/NodeVmUtils.ts | 68 ++++++++++ .../src/components/nodes/NodesPage.tsx | 51 +++----- .../BareMetalInventoryItems.tsx | 117 ++++++++++++++++++ .../nodes/node-dashboard/InventoryCard.tsx | 90 +++++++++++--- 6 files changed, 336 insertions(+), 60 deletions(-) create mode 100644 frontend/packages/console-app/src/components/nodes/NodeBareMetalUtils.ts create mode 100644 frontend/packages/console-app/src/components/nodes/NodeVmUtils.ts create mode 100644 frontend/packages/console-app/src/components/nodes/node-dashboard/BareMetalInventoryItems.tsx diff --git a/frontend/packages/console-app/locales/en/console-app.json b/frontend/packages/console-app/locales/en/console-app.json index 57bb8ade81d..30df8116634 100644 --- a/frontend/packages/console-app/locales/en/console-app.json +++ b/frontend/packages/console-app/locales/en/console-app.json @@ -354,6 +354,10 @@ "Mark unschedulable": "Mark unschedulable", "View events": "View events", "Activity": "Activity", + "Disk": "Disk", + "Network": "Network", + "NIC": "NIC", + "CPU": "CPU", "Details": "Details", "Node name": "Node name", "Instance type": "Instance type", @@ -361,8 +365,12 @@ "Node addresses": "Node addresses", "Uptime": "Uptime", "Inventory": "Inventory", - "Image": "Image", "Images": "Images", + "Image": "Image", + "Virtual machines (CNV)": "Virtual machines (CNV)", + "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.", + "Virtual machine": "Virtual machine", + "Virtual machines": "Virtual machines", "Health checks": "Health checks", "See details": "See details", "{{ cpuMessage }}": "{{ cpuMessage }}", @@ -374,7 +382,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", - "CPU": "CPU", "Memory": "Memory", "Network in": "Network in", "Network out": "Network out", @@ -407,8 +414,6 @@ "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", diff --git a/frontend/packages/console-app/src/components/nodes/NodeBareMetalUtils.ts b/frontend/packages/console-app/src/components/nodes/NodeBareMetalUtils.ts new file mode 100644 index 00000000000..ee5de0c7c51 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/NodeBareMetalUtils.ts @@ -0,0 +1,57 @@ +import { + K8sGroupVersionKind, + K8sResourceKind, + WatchK8sResult, +} from '@console/dynamic-plugin-sdk/src'; +import { useFlag } from '@console/dynamic-plugin-sdk/src/utils/flags'; +import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks'; +import { K8sKind } from '@console/internal/module/k8s'; + +export const BAREMETAL_FLAG = 'BAREMETAL'; + +export const BareMetalHostModel: K8sKind = { + label: 'Bare Metal Host', + labelPlural: 'Bare Metal Hosts', + apiVersion: 'v1alpha1', + apiGroup: 'metal3.io', + plural: 'baremetalhosts', + abbr: 'BMH', + namespaced: true, + kind: 'BareMetalHost', + id: 'baremetalhost', + crd: true, +}; + +export const BareMetalHostGroupVersionKind: K8sGroupVersionKind = { + group: 'metal3.io', + kind: 'BareMetalHost', + version: 'v1alpha1', +}; + +export const useIsBareMetalPluginActive = () => useFlag(BAREMETAL_FLAG); + +export const useWatchBareMetalHost = (nodeName: string): WatchK8sResult => { + const isBareMetalPluginActive = useIsBareMetalPluginActive(); + + const [bareMetalHosts, bareMetalHostsLoaded, bareMetalHostsLoadError] = useK8sWatchResource< + K8sResourceKind[] + >( + isBareMetalPluginActive + ? { + isList: true, + groupVersionKind: BareMetalHostGroupVersionKind, + fieldSelector: `metadata.name=${nodeName}`, + } + : undefined, + ); + + return [bareMetalHosts?.[0], bareMetalHostsLoaded, bareMetalHostsLoadError]; +}; + +export const metricsFromBareMetalHosts = ( + bareMetalHost?: K8sResourceKind, +): { disks?: number; nics?: number; cpus?: number } => ({ + disks: bareMetalHost?.status?.hardware?.storage?.length, + nics: bareMetalHost?.status?.hardware?.nics?.length, + cpus: bareMetalHost?.status?.hardware?.cpu?.count, +}); diff --git a/frontend/packages/console-app/src/components/nodes/NodeVmUtils.ts b/frontend/packages/console-app/src/components/nodes/NodeVmUtils.ts new file mode 100644 index 00000000000..cc8d87e1a5e --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/NodeVmUtils.ts @@ -0,0 +1,68 @@ +import { + K8sGroupVersionKind, + K8sResourceKind, + WatchK8sResult, +} from '@console/dynamic-plugin-sdk/src'; +import { useFlag } from '@console/dynamic-plugin-sdk/src/utils/flags'; +import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks'; +import { K8sKind } from '@console/internal/module/k8s'; + +export const VirtualMachineModel: K8sKind = { + label: 'VirtualMachine', + labelPlural: 'VirtualMachines', + apiVersion: 'v1', + apiGroup: 'kubevirt.io', + plural: 'virtualmachines', + abbr: 'VM', + namespaced: true, + kind: 'VirtualMachine', + id: 'virtualmachine', + crd: true, +}; + +// 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', + kind: 'VirtualMachineInstance', + version: 'v1', +}; + +export const useIsKubevirtPluginActive = () => { + const kubevirtFeature = useFlag('KUBEVIRT_DYNAMIC'); + + return ( + Array.isArray(window.SERVER_FLAGS?.consolePlugins) && + window.SERVER_FLAGS.consolePlugins.includes('kubevirt-plugin') && + kubevirtFeature + ); +}; + +// Return all matching VMIs, if no nodeName is given, return all VMIs. +export const filterVirtualMachineInstancesByNode = (vmis: K8sResourceKind[], nodeName?: string) => + vmis?.filter((vm) => !nodeName || vm.status?.nodeName === nodeName) ?? []; + +// Watch VMIs and return all matching VMIs by nodeName if given, if not, return all VMIs. +export const useWatchVirtualMachineInstances = ( + nodeName?: string, +): WatchK8sResult => { + const isKubevirtPluginActive = useIsKubevirtPluginActive(); + + const [ + virtualMachineInstances, + virtualMachineInstancesLoaded, + virtualMachineInstancesLoadError, + ] = useK8sWatchResource( + isKubevirtPluginActive + ? { + isList: true, + groupVersionKind: VirtualMachineInstanceGroupVersionKind, + } + : undefined, + ); + + return [ + filterVirtualMachineInstancesByNode(virtualMachineInstances, nodeName), + virtualMachineInstancesLoaded, + virtualMachineInstancesLoadError, + ]; +}; diff --git a/frontend/packages/console-app/src/components/nodes/NodesPage.tsx b/frontend/packages/console-app/src/components/nodes/NodesPage.tsx index 80c6e0aca1b..73ad757072b 100644 --- a/frontend/packages/console-app/src/components/nodes/NodesPage.tsx +++ b/frontend/packages/console-app/src/components/nodes/NodesPage.tsx @@ -17,17 +17,19 @@ import { ConsoleDataViewRow, ResourceFilters, } from '@console/app/src/components/data-view/types'; +import { + filterVirtualMachineInstancesByNode, + useIsKubevirtPluginActive, + useWatchVirtualMachineInstances, +} from '@console/app/src/components/nodes/NodeVmUtils'; import { getGroupVersionKindForResource, K8sModel, ListPageBody, useAccessReview, - useFlag, } from '@console/dynamic-plugin-sdk/src/api/dynamic-core-api'; import { - K8sGroupVersionKind, K8sResourceCommon, - K8sResourceKind, NodeCertificateSigningRequestKind, OwnerReference, RowProps, @@ -101,13 +103,6 @@ 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', @@ -902,40 +897,22 @@ export const NodesPage: FC = ({ selector }) => { CertificateSigningRequestKind[] >(CertificateSigningRequestModel); - const kubevirtFeature = useFlag('KUBEVIRT_DYNAMIC'); - const isKubevirtPluginActive = - Array.isArray(window.SERVER_FLAGS.consolePlugins) && - window.SERVER_FLAGS.consolePlugins.includes('kubevirt-plugin') && - kubevirtFeature; + // TODO: Remove VMs count column if/when the plugin is able to add the VMs count column + const isKubevirtPluginActive = useIsKubevirtPluginActive(); - const [vmis, vmisLoaded, vmisLoadError] = useK8sWatchResource( - isKubevirtPluginActive - ? { - isList: true, - groupVersionKind: VirtualMachineInstanceGroupVersionKind, - } - : undefined, - ); + const [vmis, vmisLoaded, vmisLoadError] = useWatchVirtualMachineInstances(); const vmsByNode = useMemo(() => { if (!isKubevirtPluginActive || !nodesLoaded || nodesLoadError || !vmisLoaded || vmisLoadError) { return undefined; } - const map = new Map(nodes.map((node) => [node.metadata.name, []])); - vmis.forEach((vmi) => { - const nodeName = vmi.status?.nodeName; - if (!nodeName) { - return; - } - const nodeVMs = map.get(nodeName); - if (nodeVMs) { - nodeVMs.push(vmi); - } else { - map.set(nodeName, [vmi]); - } - }); - return map; + return new Map( + nodes.map((node) => [ + `${node.metadata.name}`, + filterVirtualMachineInstancesByNode(vmis, node.metadata.name), + ]), + ); }, [isKubevirtPluginActive, nodes, nodesLoadError, nodesLoaded, vmis, vmisLoadError, vmisLoaded]); useEffect(() => { 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 new file mode 100644 index 00000000000..82d3f8bf8a8 --- /dev/null +++ b/frontend/packages/console-app/src/components/nodes/node-dashboard/BareMetalInventoryItems.tsx @@ -0,0 +1,117 @@ +import type { FC } from 'react'; +import { useContext } from 'react'; +import { + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, +} from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom-v5-compat'; +import { + BareMetalHostModel, + metricsFromBareMetalHosts, + useIsBareMetalPluginActive, + useWatchBareMetalHost, +} from '@console/app/src/components/nodes/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'; +import { NodeDashboardContext } from './NodeDashboardContext'; + +type BareMetalInventoryItemsProps = { + loaded: boolean; + loadError?: unknown; + title: string; + itemsTitle?: string; + count: number | undefined; + linkTo?: string; +}; + +const BareMetalInventoryItem: FC = ({ + loaded, + loadError, + title, + itemsTitle, + count, + linkTo, +}) => { + return ( + + {title} + + {!loaded ? ( + + ) : loadError || count === undefined ? ( + DASH + ) : linkTo ? ( + + + + ) : ( + + )} + + + ); +}; + +const BareMetalInventoryItems: FC = () => { + const { obj } = useContext(NodeDashboardContext); + const { t } = useTranslation(); + + const showBareMetal = useIsBareMetalPluginActive(); + + const [bareMetalHost, bareMetalHostLoaded, bareMetalHostLoadError] = useWatchBareMetalHost( + obj.metadata.name, + ); + + if (!showBareMetal) { + return null; + } + + const { disks, nics, cpus } = metricsFromBareMetalHosts(bareMetalHost); + + return ( + <> + + + + + ); +}; + +export default BareMetalInventoryItems; 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 2384dee4c15..ce4fc5ae714 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 @@ -1,7 +1,18 @@ import type { FC } from 'react'; import { useMemo, useContext } from 'react'; -import { Card, CardBody, CardHeader, CardTitle, Stack, StackItem } from '@patternfly/react-core'; +import { + Card, + CardBody, + CardHeader, + CardTitle, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, +} from '@patternfly/react-core'; import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom-v5-compat'; +import BareMetalInventoryItems from '@console/app/src/components/nodes/node-dashboard/BareMetalInventoryItems'; 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'; @@ -12,6 +23,12 @@ import { StatusGroupMapper, } 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, + useWatchVirtualMachineInstances, + VirtualMachineModel, +} from '../NodeVmUtils'; import { NodeDashboardContext } from './NodeDashboardContext'; export const NodeInventoryItem: FC = ({ nodeName, model, mapper }) => { @@ -42,30 +59,65 @@ const InventoryCard: FC = () => { const { obj } = useContext(NodeDashboardContext); const { t } = useTranslation(); + const showVms = useIsKubevirtPluginActive(); + const [vms, vmsLoaded, vmsLoadError] = useWatchVirtualMachineInstances(obj.metadata.name); + return ( {t('console-app~Inventory')} - - - - - - - - + + + {t('console-app~Pods')} + + + + + + {t('console-app~Images')} + + + + + + {showVms ? ( + + + + + + + + + ) : null} + );