From a0ff994061262f40a0c2d57840d666b2204a4730 Mon Sep 17 00:00:00 2001 From: Damian Badura <45110612+dbadura@users.noreply.github.com> Date: Fri, 15 Nov 2024 08:50:21 +0100 Subject: [PATCH] feat: Show resoruces per node (#3452) * first impl * improve tests * improve details cards * reorder machine infp * apply review suggestions * improve digit display * fix --- public/i18n/en.yaml | 9 +- .../Nodes/MachineInfo/MachineInfo.js | 23 ++-- .../Nodes/NodeDetails/NodeDetails.js | 6 +- src/components/Nodes/NodeDetailsCard.js | 3 - .../Nodes/NodeResources/NodeResources.js | 68 ++++++++++- src/components/Nodes/nodeQueries.js | 92 +++++++++++++- src/components/Nodes/nodeQueries.test.js | 115 ++++++++++++++++++ src/resources/Namespaces/ResourcesUsage.js | 33 ++++- .../UI5RadialChart/UI5RadialChart.js | 2 +- src/shared/utils/helpers.js | 4 +- 10 files changed, 330 insertions(+), 25 deletions(-) create mode 100644 src/components/Nodes/nodeQueries.test.js diff --git a/public/i18n/en.yaml b/public/i18n/en.yaml index 7f13654557..a1aa4b7362 100644 --- a/public/i18n/en.yaml +++ b/public/i18n/en.yaml @@ -12,6 +12,10 @@ cluster-overview: statistics: cpu-usage: 'CPU Usage' memory-usage: 'Memory Usage' + cpu-requests: 'CPU requests' + memory-requests: 'Memory requests' + cpu-limits: 'CPU limits' + memory-limits: 'Memory limits' pods-overview: 'Pods Overview' total-pods: 'Total Pods' healthy-pods: 'Healthy Pods' @@ -795,7 +799,7 @@ limit-ranges: name_singular: LimitRange title: Limit Ranges machine-info: - architecture-cpus: Architecture and CPUs + architecture: Architecture cpus: CPUs gib: GiB kube-proxy-version: Kube proxy version @@ -876,7 +880,8 @@ node-details: all: All messages information: Information internal-ip: Internal IP - pod-cidr: Pod CIDR + pod-cidr: Pod CIDRs + provider: Provider warnings: Warnings nodes: title: Nodes diff --git a/src/components/Nodes/MachineInfo/MachineInfo.js b/src/components/Nodes/MachineInfo/MachineInfo.js index 19ed7baf4a..9af58876ed 100644 --- a/src/components/Nodes/MachineInfo/MachineInfo.js +++ b/src/components/Nodes/MachineInfo/MachineInfo.js @@ -3,7 +3,7 @@ import './MachineInfo.scss'; import { DynamicPageComponent } from 'shared/components/DynamicPageComponent/DynamicPageComponent'; import ResourceDetailsCard from 'shared/components/ResourceDetails/ResourceDetailsCard'; -export function MachineInfo({ nodeInfo, capacity }) { +export function MachineInfo({ nodeInfo, capacity, spec }) { const formattedMemory = Math.round((parseInt(capacity.memory) / 1024 / 1024) * 10) / 10; const { t } = useTranslation(); @@ -20,19 +20,24 @@ export function MachineInfo({ nodeInfo, capacity }) { > {`${nodeInfo.operatingSystem} (${nodeInfo.osImage})`} - - {`${nodeInfo.architecture}, ${capacity.cpu} ${t( - 'machine-info.cpus', - )}`} + + {spec.providerID} - - {capacity.pods} + + {nodeInfo.architecture} + + + {capacity.cpu} {`${formattedMemory} ${t('machine-info.gib')}`} + + {capacity.pods} + + + {spec.podCIDRs.join(',')} + diff --git a/src/components/Nodes/NodeDetails/NodeDetails.js b/src/components/Nodes/NodeDetails/NodeDetails.js index 106e563c44..57362678c2 100644 --- a/src/components/Nodes/NodeDetails/NodeDetails.js +++ b/src/components/Nodes/NodeDetails/NodeDetails.js @@ -1,6 +1,6 @@ import { useWindowTitle } from 'shared/hooks/useWindowTitle'; import { useTranslation } from 'react-i18next'; -import { useNodeQuery } from '../nodeQueries'; +import { useNodeQuery, useResourceByNode } from '../nodeQueries'; import { NodeDetailsCard } from '../NodeDetailsCard'; import { MachineInfo } from '../MachineInfo/MachineInfo'; import { NodeResources } from '../NodeResources/NodeResources'; @@ -17,6 +17,7 @@ export default function NodeDetails({ nodeName }) { const { data, error, loading } = useNodeQuery(nodeName); const { t } = useTranslation(); useWindowTitle(t('nodes.title_details', { nodeName })); + const { data: resources } = useResourceByNode(nodeName); const filterByHost = e => e.source.host === nodeName; const Events = ( @@ -53,6 +54,7 @@ export default function NodeDetails({ nodeName }) { - <NodeResources {...data} /> + <NodeResources metrics={data.metrics} resources={resources} /> </div> {Events} </> diff --git a/src/components/Nodes/NodeDetailsCard.js b/src/components/Nodes/NodeDetailsCard.js index 2274c5e6d9..a5510d0aba 100644 --- a/src/components/Nodes/NodeDetailsCard.js +++ b/src/components/Nodes/NodeDetailsCard.js @@ -27,9 +27,6 @@ export function NodeDetailsCard({ node, loading, error }) { timestamp={node.metadata.creationTimestamp} /> </DynamicPageComponent.Column> - <DynamicPageComponent.Column title={t('node-details.pod-cidr')}> - {node.spec.podCIDR} - </DynamicPageComponent.Column> <DynamicPageComponent.Column title={t('node-details.internal-ip')} > diff --git a/src/components/Nodes/NodeResources/NodeResources.js b/src/components/Nodes/NodeResources/NodeResources.js index 6c9bdf0461..9a1e44cfc0 100644 --- a/src/components/Nodes/NodeResources/NodeResources.js +++ b/src/components/Nodes/NodeResources/NodeResources.js @@ -4,7 +4,7 @@ import { Card, CardHeader } from '@ui5/webcomponents-react'; import { roundTwoDecimals } from 'shared/utils/helpers'; import './NodeResources.scss'; -export function NodeResources({ metrics }) { +export function NodeResources({ metrics, resources }) { const { t } = useTranslation(); const { cpu, memory } = metrics || {}; @@ -42,6 +42,72 @@ export function NodeResources({ metrics }) { )}GiB / ${roundTwoDecimals(memory.capacity)}GiB`} /> </Card> + <Card + className="radial-chart-card" + header={ + <CardHeader + titleText={t('cluster-overview.statistics.cpu-requests')} + /> + } + > + <UI5RadialChart + color="var(--sapChart_OrderedColor_5)" + value={resources?.requests?.cpu} + max={cpu.capacity} + additionalInfo={`${roundTwoDecimals( + resources?.requests?.cpu, + )}m / ${roundTwoDecimals(cpu.capacity)}m`} + /> + </Card> + <Card + className="radial-chart-card" + header={ + <CardHeader + titleText={t('cluster-overview.statistics.memory-requests')} + /> + } + > + <UI5RadialChart + color="var(--sapChart_OrderedColor_6)" + value={resources?.requests?.memory} + max={memory.capacity} + additionalInfo={`${roundTwoDecimals( + resources.requests?.memory, + )}GiB / ${roundTwoDecimals(memory.capacity)}GiB`} + /> + </Card> + <Card + className="radial-chart-card" + header={ + <CardHeader titleText={t('cluster-overview.statistics.cpu-limits')} /> + } + > + <UI5RadialChart + color="var(--sapChart_OrderedColor_5)" + value={resources?.limits?.cpu} + max={cpu.capacity} + additionalInfo={`${roundTwoDecimals( + resources?.limits?.cpu, + )}m / ${roundTwoDecimals(cpu.capacity)}m`} + /> + </Card> + <Card + className="radial-chart-card" + header={ + <CardHeader + titleText={t('cluster-overview.statistics.memory-limits')} + /> + } + > + <UI5RadialChart + color="var(--sapChart_OrderedColor_6)" + value={resources?.limits?.memory} + max={memory.capacity} + additionalInfo={`${roundTwoDecimals( + resources.limits.memory, + )}GiB / ${roundTwoDecimals(memory.capacity)}GiB`} + /> + </Card> </> ) : ( t('components.error-panel.error') diff --git a/src/components/Nodes/nodeQueries.js b/src/components/Nodes/nodeQueries.js index 74b157c538..335e35bf15 100644 --- a/src/components/Nodes/nodeQueries.js +++ b/src/components/Nodes/nodeQueries.js @@ -1,5 +1,9 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { useGet } from 'shared/hooks/BackendAPI/useGet'; +import { + getBytes, + getCpus, +} from '../../resources/Namespaces/ResourcesUsage.js'; const round = (num, places) => Math.round(num * Math.pow(10, places)) / Math.pow(10, places); @@ -109,3 +113,89 @@ export function useNodeQuery(nodeName) { loading: metricsLoading || nodeLoading, }; } + +const emptyResources = { + limits: { + cpu: 0, + memory: 0, + }, + requests: { + cpu: 0, + memory: 0, + }, +}; + +function addResources(a, b) { + if (!a) { + if (!b) { + return structuredClone(emptyResources); + } + return b; + } + if (!b) { + if (!a) { + return structuredClone(emptyResources); + } + return a; + } + return { + limits: { + cpu: getCpus(a?.limits?.cpu) + getCpus(b?.limits?.cpu), + memory: getBytes(a?.limits?.memory) + getBytes(b?.limits?.memory), + }, + requests: { + cpu: getCpus(a?.requests?.cpu) + getCpus(b?.requests?.cpu), + memory: getBytes(a?.requests?.memory) + getBytes(b?.requests?.memory), + }, + }; +} + +function sumContainersResources(containers) { + return containers?.reduce((containerAccu, container) => { + return addResources(containerAccu, container.resources); + }, structuredClone(emptyResources)); +} + +export function calcNodeResources(pods) { + const nodeResources = + pods?.items?.reduce((accumulator, pod) => { + if (pod?.spec?.containers) { + const containerResources = sumContainersResources( + pod?.spec?.containers, + ); + return addResources(accumulator, containerResources); + } + return accumulator; + }, structuredClone(emptyResources)) || structuredClone(emptyResources); + + return { + limits: { + cpu: nodeResources.limits.cpu * 1000, + memory: nodeResources.limits.memory / Math.pow(1024, 3), + }, + requests: { + cpu: nodeResources.requests.cpu * 1000, + memory: nodeResources.requests.memory / Math.pow(1024, 3), + }, + }; +} + +export function useResourceByNode(nodeName) { + const [data, setData] = React.useState(null); + const { data: pods, error, loading } = useGet( + `/api/v1/pods?fieldSelector=spec.nodeName=${nodeName},status.phase!=Failed,status.phase!=Succeeded&limit=500`, + ); + + const nodeResources = useMemo(() => calcNodeResources(pods), [pods]); + + React.useEffect(() => { + if (nodeResources) { + setData(nodeResources); + } + }, [nodeResources]); + return { + data, + error, + loading, + }; +} diff --git a/src/components/Nodes/nodeQueries.test.js b/src/components/Nodes/nodeQueries.test.js new file mode 100644 index 0000000000..b5258567a4 --- /dev/null +++ b/src/components/Nodes/nodeQueries.test.js @@ -0,0 +1,115 @@ +import { calcNodeResources } from './nodeQueries.js'; +describe('Calculate resources for node', () => { + const testCases = [ + { + name: 'Pods with one container', + pods: { + items: [ + fixPod([fixResources('7m', '70Mi', '14m', '140Mi')]), + fixPod([fixResources('3m', '30Mi', '6m', '60Mi')]), + ], + }, + expectedValue: { + limits: { + cpu: 10, + memory: 100.0 / 1024, + }, + requests: { + cpu: 20, + memory: 200.0 / 1024, + }, + }, + }, + { + name: 'Pods with two containers', + pods: { + items: [ + fixPod([ + fixResources('7m', '70Mi', '14m', '140Mi'), + fixResources('7m', '70Mi', '14m', '140Mi'), + ]), + fixPod([ + fixResources('3m', '30Mi', '6m', '60Mi'), + fixResources('5m', '50Mi', '11m', '110Mi'), + ]), + ], + }, + expectedValue: { + limits: { + cpu: 22, + memory: 220.0 / 1024, + }, + requests: { + cpu: 45, + memory: 450.0 / 1024, + }, + }, + }, + { + name: 'Pods container and one without resources', + pods: { + items: [ + fixPod([fixResources('7m', '70Mi', '14m', '140Mi')]), + fixPodWithoutResources(), + ], + }, + expectedValue: { + limits: { + cpu: 7, + memory: 70.0 / 1024, + }, + requests: { + cpu: 14, + memory: 140.0 / 1024, + }, + }, + }, + ]; + + testCases.forEach(tc => { + test(tc.name, () => { + //WHEN + const resources = calcNodeResources(tc.pods); + + //THEN + expect(resources).toStrictEqual(tc.expectedValue); + }); + }); +}); + +function fixPod(resources) { + return { + spec: { + containers: resources.map(item => { + return { + resources: item, + }; + }), + }, + }; +} + +function fixPodWithoutResources() { + return { + spec: { + containers: [ + { + resources: {}, + }, + ], + }, + }; +} + +function fixResources(cpuLimit, memoryLimit, cpuRequest, memoryRequest) { + return { + limits: { + cpu: cpuLimit, + memory: memoryLimit, + }, + requests: { + cpu: cpuRequest, + memory: memoryRequest, + }, + }; +} diff --git a/src/resources/Namespaces/ResourcesUsage.js b/src/resources/Namespaces/ResourcesUsage.js index bb144aa6db..718403e83f 100644 --- a/src/resources/Namespaces/ResourcesUsage.js +++ b/src/resources/Namespaces/ResourcesUsage.js @@ -18,25 +18,43 @@ const MEMORY_SUFFIX_POWER = { Ti: 2 ** 40, }; +const CPU_SUFFIX_POWER = { + m: 1e-3, +}; + export function getBytes(memoryString) { if (!memoryString || memoryString === '0') { return 0; } - const suffixMatch = memoryString.match(/\D+$/); + const suffixMatch = String(memoryString).match(/\D+$/); if (!suffixMatch?.length) { - console.warn('error'); - return 0; + return memoryString; } const suffix = suffixMatch[0]; - const number = memoryString.replace(suffix, ''); + const number = String(memoryString).replace(suffix, ''); const suffixPower = MEMORY_SUFFIX_POWER[suffix]; if (!suffixPower) { - console.warn('error'); + return number; + } + + return number * suffixPower; +} + +export function getCpus(cpuString) { + if (!cpuString || cpuString === '0') { return 0; } + const suffix = String(cpuString).slice(-1); + + const suffixPower = CPU_SUFFIX_POWER[suffix]; + if (!suffixPower) { + return parseFloat(cpuString); + } + + const number = String(cpuString).replace(suffix, ''); return number * suffixPower; } @@ -45,6 +63,11 @@ export function bytesToHumanReadable(bytes) { return getSIPrefix(bytes, true, { withoutSpace: true }).string; } +export function cpusToHumanReadable(cpus) { + if (!cpus) return cpus; + return cpus / MEMORY_SUFFIX_POWER['m'] + 'm'; +} + const MemoryRequestsCircle = ({ resourceQuotas, isLoading }) => { const { t } = useTranslation(); diff --git a/src/shared/components/UI5RadialChart/UI5RadialChart.js b/src/shared/components/UI5RadialChart/UI5RadialChart.js index 05316d62fb..df5e2660d3 100644 --- a/src/shared/components/UI5RadialChart/UI5RadialChart.js +++ b/src/shared/components/UI5RadialChart/UI5RadialChart.js @@ -14,7 +14,7 @@ export const UI5RadialChart = ({ additionalInfo = '', }) => { const percent = max && value ? Math.round((value * 100) / max) : 0; - const text = percent + '%'; + const text = (percent > 10_000 ? percent.toPrecision(3) : percent) + '%'; const textSize = size / Math.max(3.5, text.length) + 'px'; const classnames = classNames(`radial-chart`, { diff --git a/src/shared/utils/helpers.js b/src/shared/utils/helpers.js index fb2cbd68b0..8dfedf7f83 100644 --- a/src/shared/utils/helpers.js +++ b/src/shared/utils/helpers.js @@ -139,5 +139,7 @@ export function buildPathsFromObject(object, path = '') { } export function roundTwoDecimals(number) { - return parseFloat(number.toFixed(2)); + return number > 100_000 + ? Number.parseFloat(number).toExponential(2) + : Number.parseFloat(number.toFixed(2)); }