diff --git a/.changeset/cool-swans-love.md b/.changeset/cool-swans-love.md new file mode 100644 index 0000000000..162ffba46e --- /dev/null +++ b/.changeset/cool-swans-love.md @@ -0,0 +1,6 @@ +--- +'@backstage/core-components': patch +'@backstage/plugin-kubernetes': patch +--- + +Add resource utilization to Pod Drawer diff --git a/packages/core-components/src/components/ProgressBars/LinearGauge.test.tsx b/packages/core-components/src/components/ProgressBars/LinearGauge.test.tsx index f7260a9a65..1d76f59faf 100644 --- a/packages/core-components/src/components/ProgressBars/LinearGauge.test.tsx +++ b/packages/core-components/src/components/ProgressBars/LinearGauge.test.tsx @@ -34,4 +34,28 @@ describe('', () => { const { getByTitle } = await renderInTestApp(); expect(getByTitle('100%')).toBeInTheDocument(); }); + + it('renders thick', async () => { + const { container, getByTitle } = await renderInTestApp( + , + ); + expect(getByTitle('50%')).toBeInTheDocument(); + const linePaths = container.getElementsByClassName('rc-progress-line-path'); + expect(linePaths).toHaveLength(1); + const linePath = linePaths[0]; + expect(linePath).toHaveAttribute('stroke-width'); + expect(linePath.getAttribute('stroke-width')).toBe('4'); + }); + + it('renders thin', async () => { + const { container, getByTitle } = await renderInTestApp( + , + ); + expect(getByTitle('50%')).toBeInTheDocument(); + const linePaths = container.getElementsByClassName('rc-progress-line-path'); + expect(linePaths).toHaveLength(1); + const linePath = linePaths[0]; + expect(linePath).toHaveAttribute('stroke-width'); + expect(linePath.getAttribute('stroke-width')).toBe('1'); + }); }); diff --git a/packages/core-components/src/components/ProgressBars/LinearGauge.tsx b/packages/core-components/src/components/ProgressBars/LinearGauge.tsx index 5bfe1b7a61..8d45b83571 100644 --- a/packages/core-components/src/components/ProgressBars/LinearGauge.tsx +++ b/packages/core-components/src/components/ProgressBars/LinearGauge.tsx @@ -27,11 +27,12 @@ type Props = { * Progress value between 0.0 - 1.0. */ value: number; + width?: 'thick' | 'thin'; getColor?: GaugePropsGetColor; }; export function LinearGauge(props: Props) { - const { value, getColor = getProgressColor } = props; + const { value, getColor = getProgressColor, width = 'thick' } = props; const { palette } = useTheme(); if (isNaN(value)) { return null; @@ -40,6 +41,7 @@ export function LinearGauge(props: Props) { if (percent > 100) { percent = 100; } + const lineWidth = width === 'thick' ? 4 : 1; const strokeColor = getColor({ palette, value: percent, @@ -51,8 +53,8 @@ export function LinearGauge(props: Props) { diff --git a/plugins/kubernetes/api-report.md b/plugins/kubernetes/api-report.md index e117e3627d..7cef17af7b 100644 --- a/plugins/kubernetes/api-report.md +++ b/plugins/kubernetes/api-report.md @@ -8,6 +8,7 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { AsyncState } from 'react-use/lib/useAsyncFn'; import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { ClientContainerStatus } from '@backstage/plugin-kubernetes-common'; import { ClientPodStatus } from '@backstage/plugin-kubernetes-common'; import { ClusterAttributes } from '@backstage/plugin-kubernetes-common'; import { ClusterObjects } from '@backstage/plugin-kubernetes-common'; @@ -19,6 +20,7 @@ import { Event as Event_2 } from 'kubernetes-models/v1'; import { IContainer } from 'kubernetes-models/v1'; import { IContainerStatus } from 'kubernetes-models/v1'; import { IdentityApi } from '@backstage/core-plugin-api'; +import { IIoK8sApimachineryPkgApisMetaV1ObjectMeta } from '@kubernetes-models/apimachinery/apis/meta/v1/ObjectMeta'; import { IObjectMeta } from '@kubernetes-models/apimachinery/apis/meta/v1/ObjectMeta'; import type { JsonObject } from '@backstage/types'; import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; @@ -29,6 +31,7 @@ import { Pod } from 'kubernetes-models/v1/Pod'; import { Pod as Pod_2 } from 'kubernetes-models/v1'; import { default as React_2 } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; +import { TypeMeta } from '@kubernetes-models/base'; import { V1ConfigMap } from '@kubernetes/client-node'; import { V1CronJob } from '@kubernetes/client-node'; import { V1Deployment } from '@kubernetes/client-node'; @@ -87,6 +90,8 @@ export const ContainerCard: React_2.FC; // @public export interface ContainerCardProps { + // (undocumented) + containerMetrics?: ClientContainerStatus; // (undocumented) containerSpec?: IContainer; // (undocumented) @@ -139,8 +144,6 @@ export interface DetectedError { proposedFix?: ProposedFix; // (undocumented) severity: ErrorSeverity; - // Warning: (ae-forgotten-export) The symbol "ResourceRef" needs to be exported by the entry point index.d.ts - // // (undocumented) sourceRef: ResourceRef; // (undocumented) @@ -150,6 +153,9 @@ export interface DetectedError { // @public export type DetectedErrorsByCluster = Map; +// @public +export const DetectedErrorsContext: React_2.Context; + // @public export const detectErrors: ( objects: ObjectsByEntityResponse, @@ -176,6 +182,11 @@ export interface ErrorListProps { podAndErrors: PodAndErrors[]; } +// @public (undocumented) +export type ErrorMatcher = { + metadata?: IIoK8sApimachineryPkgApisMetaV1ObjectMeta; +} & TypeMeta; + // Warning: (ae-forgotten-export) The symbol "ErrorPanelProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "ErrorPanel" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -642,6 +653,16 @@ export interface PodLogsProps { previous?: boolean; } +// @public +export const PodMetricsContext: React_2.Context>; + +// Warning: (ae-missing-release-tag) "PodMetricsMatcher" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type PodMetricsMatcher = { + metadata?: IObjectMeta; +}; + // Warning: (ae-missing-release-tag) "PodNamesWithErrorsContext" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -673,6 +694,29 @@ export const PodsTable: ({ extraColumns, }: PodsTablesProps) => JSX.Element; +// @public +export interface ResourceRef { + // (undocumented) + apiGroup: string; + // (undocumented) + kind: string; + // (undocumented) + name: string; + // (undocumented) + namespace: string; +} + +// Warning: (ae-forgotten-export) The symbol "ResourceUtilizationProps" needs to be exported by the entry point index.d.ts +// +// @public +export const ResourceUtilization: ({ + compressed, + title, + usage, + total, + totalFormated, +}: ResourceUtilizationProps) => JSX.Element; + // Warning: (ae-missing-release-tag) "Router" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -718,6 +762,9 @@ export const useKubernetesObjects: ( intervalMs?: number, ) => KubernetesObjects; +// @public +export const useMatchingErrors: (matcher: ErrorMatcher) => DetectedError[]; + // @public export const usePodLogs: ({ containerScope, @@ -725,4 +772,10 @@ export const usePodLogs: ({ }: PodLogsOptions) => AsyncState<{ text: string; }>; + +// @public +export const usePodMetrics: ( + clusterName: string, + matcher: PodMetricsMatcher, +) => ClientPodStatus | undefined; ``` diff --git a/plugins/kubernetes/src/components/Cluster/Cluster.tsx b/plugins/kubernetes/src/components/Cluster/Cluster.tsx index 58025955b2..bea7a46d37 100644 --- a/plugins/kubernetes/src/components/Cluster/Cluster.tsx +++ b/plugins/kubernetes/src/components/Cluster/Cluster.tsx @@ -41,7 +41,7 @@ import { } from '../../hooks'; import { StatusError, StatusOK } from '@backstage/core-components'; -import { PodNamesWithMetricsContext } from '../../hooks/PodNamesWithMetrics'; +import { PodMetricsContext } from '../../hooks/usePodMetrics'; type ClusterSummaryProps = { clusterName: string; @@ -111,19 +111,13 @@ type ClusterProps = { export const Cluster = ({ clusterObjects, podsWithErrors }: ClusterProps) => { const groupedResponses = groupResponses(clusterObjects.resources); - const podNameToMetrics = clusterObjects.podMetrics - .flat() - .reduce((accum, next) => { - const name = next.pod.metadata?.name; - if (name !== undefined) { - accum.set(name, next); - } - return accum; - }, new Map()); + + const podMetricsMap = new Map(); + podMetricsMap.set(clusterObjects.cluster.name, clusterObjects.podMetrics); return ( - + }> @@ -169,7 +163,7 @@ export const Cluster = ({ clusterObjects, podsWithErrors }: ClusterProps) => { - + ); diff --git a/plugins/kubernetes/src/components/Pods/PodDrawer/ContainerCard.tsx b/plugins/kubernetes/src/components/Pods/PodDrawer/ContainerCard.tsx index 970982d7a3..9c4272248b 100644 --- a/plugins/kubernetes/src/components/Pods/PodDrawer/ContainerCard.tsx +++ b/plugins/kubernetes/src/components/Pods/PodDrawer/ContainerCard.tsx @@ -29,6 +29,9 @@ import { DateTime } from 'luxon'; import { PodScope, PodLogsDialog } from '../PodLogs'; import { StructuredMetadataTable } from '@backstage/core-components'; +import { ClientContainerStatus } from '@backstage/plugin-kubernetes-common'; +import { ResourceUtilization } from '../../ResourceUtilization'; +import { bytesToMiB, formatMilicores } from '../../../utils/resources'; const getContainerHealthChecks = ( containerSpec: IContainer, @@ -92,6 +95,7 @@ export interface ContainerCardProps { podScope: PodScope; containerSpec?: IContainer; containerStatus: IContainerStatus; + containerMetrics?: ClientContainerStatus; } /** @@ -103,6 +107,7 @@ export const ContainerCard: React.FC = ({ podScope, containerSpec, containerStatus, + containerMetrics, }: ContainerCardProps) => { // This should never be undefined if (containerSpec === undefined) { @@ -168,6 +173,53 @@ export const ContainerCard: React.FC = ({ )} /> + {containerMetrics && ( + + + + Resource utilization + + + + + + + + + + )} diff --git a/plugins/kubernetes/src/components/Pods/PodDrawer/PodDrawer.tsx b/plugins/kubernetes/src/components/Pods/PodDrawer/PodDrawer.tsx index 8e616df14e..e70eeb8fb7 100644 --- a/plugins/kubernetes/src/components/Pods/PodDrawer/PodDrawer.tsx +++ b/plugins/kubernetes/src/components/Pods/PodDrawer/PodDrawer.tsx @@ -33,6 +33,9 @@ import { PodAndErrors } from '../types'; import { KubernetesDrawer } from '../../KubernetesDrawer'; import { PendingPodContent } from './PendingPodContent'; import { ErrorList } from '../ErrorList'; +import { usePodMetrics } from '../../../hooks/usePodMetrics'; +import { ResourceUtilization } from '../../ResourceUtilization'; +import { bytesToMiB, formatMilicores } from '../../../utils/resources'; const useDrawerContentStyles = makeStyles((_theme: Theme) => createStyles({ @@ -76,6 +79,7 @@ interface PodDrawerProps { */ export const PodDrawer = ({ podAndErrors, open }: PodDrawerProps) => { const classes = useDrawerContentStyles(); + const podMetrics = usePodMetrics(podAndErrors.clusterName, podAndErrors.pod); return ( { } >
+ {podMetrics && ( + + + Resource utilization + + + + + + + + + + + )} {podAndErrors.pod.status?.phase === 'Pending' && ( )} @@ -111,9 +150,13 @@ export const PodDrawer = ({ podAndErrors, open }: PodDrawerProps) => { podAndErrors.pod, containerStatus.name, ); + const containerMetrics = ( + podMetrics?.containers ?? [] + ).find(c => c.container === containerStatus.name); return ( { expect(getByText('OK')).toBeInTheDocument(); }); it('should render pod, with metrics context', async () => { - const podNameToClientPodStatus = new Map(); + const clusterToClientPodStatus = new Map(); - podNameToClientPodStatus.set('dice-roller-6c8646bfd-2m5hv', { - memory: { - currentUsage: '1069056', - requestTotal: '67108864', - limitTotal: '134217728', + clusterToClientPodStatus.set('some-cluster', [ + { + pod: { + metadata: { + name: 'dice-roller-6c8646bfd-2m5hv', + namespace: 'default', + }, + }, + memory: { + currentUsage: '1069056', + requestTotal: '67108864', + limitTotal: '134217728', + }, + cpu: { + currentUsage: 0.4966115, + requestTotal: 0.05, + limitTotal: 0.05, + }, }, - cpu: { - currentUsage: 0.4966115, - requestTotal: 0.05, - limitTotal: 0.05, - }, - } as any); + ] as any); const wrapper = kubernetesProviders( undefined, undefined, - podNameToClientPodStatus, + clusterToClientPodStatus, + { + name: 'some-cluster', + }, ); const { getByText } = render( wrapper( @@ -114,7 +125,7 @@ describe('PodsTable', () => { expect(getByText('limits: 0% of 128MiB')).toBeInTheDocument(); }); it('should render placehoplder when empty metrics context', async () => { - const podNameToClientPodStatus = new Map(); + const podNameToClientPodStatus = new Map(); const wrapper = kubernetesProviders( undefined, diff --git a/plugins/kubernetes/src/components/Pods/PodsTable.tsx b/plugins/kubernetes/src/components/Pods/PodsTable.tsx index 620e91ce72..bebfbd0b5d 100644 --- a/plugins/kubernetes/src/components/Pods/PodsTable.tsx +++ b/plugins/kubernetes/src/components/Pods/PodsTable.tsx @@ -24,11 +24,12 @@ import { totalRestarts, } from '../../utils/pod'; import { Table, TableColumn } from '@backstage/core-components'; -import { PodNamesWithMetricsContext } from '../../hooks/PodNamesWithMetrics'; import { ClusterContext } from '../../hooks/Cluster'; import { useMatchingErrors } from '../../hooks/useMatchingErrors'; import { Pod } from 'kubernetes-models/v1/Pod'; import { V1Pod } from '@kubernetes/client-node'; +import { usePodMetrics } from '../../hooks/usePodMetrics'; +import { Typography } from '@material-ui/core'; export const READY_COLUMNS: PodColumns = 'READY'; export const RESOURCE_COLUMNS: PodColumns = 'RESOURCE'; @@ -74,8 +75,28 @@ const PodDrawerTrigger = ({ pod }: { pod: Pod }) => { ); }; +const Cpu = ({ clusterName, pod }: { clusterName: string; pod: Pod }) => { + const metrics = usePodMetrics(clusterName, pod); + + if (!metrics) { + return unknown; + } + + return <>{podStatusToCpuUtil(metrics)}; +}; + +const Memory = ({ clusterName, pod }: { clusterName: string; pod: Pod }) => { + const metrics = usePodMetrics(clusterName, pod); + + if (!metrics) { + return unknown; + } + + return <>{podStatusToMemoryUtil(metrics)}; +}; + export const PodsTable = ({ pods, extraColumns = [] }: PodsTablesProps) => { - const podNamesWithMetrics = useContext(PodNamesWithMetricsContext); + const cluster = useContext(ClusterContext); const defaultColumns: TableColumn[] = [ { title: 'name', @@ -104,26 +125,14 @@ export const PodsTable = ({ pods, extraColumns = [] }: PodsTablesProps) => { { title: 'CPU usage %', render: (pod: Pod) => { - const metrics = podNamesWithMetrics.get(pod.metadata?.name ?? ''); - - if (!metrics) { - return 'unknown'; - } - - return podStatusToCpuUtil(metrics); + return ; }, width: 'auto', }, { title: 'Memory usage %', render: (pod: Pod) => { - const metrics = podNamesWithMetrics.get(pod.metadata?.name ?? ''); - - if (!metrics) { - return 'unknown'; - } - - return podStatusToMemoryUtil(metrics); + return ; }, width: 'auto', }, diff --git a/plugins/kubernetes/src/components/ResourceUtilization/ResourceUtilization.test.tsx b/plugins/kubernetes/src/components/ResourceUtilization/ResourceUtilization.test.tsx new file mode 100644 index 0000000000..56ba0b7ef0 --- /dev/null +++ b/plugins/kubernetes/src/components/ResourceUtilization/ResourceUtilization.test.tsx @@ -0,0 +1,54 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { ResourceUtilization } from './ResourceUtilization'; +import { wrapInTestApp } from '@backstage/test-utils'; + +describe('ResourceUtilization', () => { + it('should render utilization', async () => { + const { getByText } = render( + wrapInTestApp( + , + ), + ); + + expect(getByText('some-title: 15%')).toBeInTheDocument(); + expect(getByText('usage: 10%')).toBeInTheDocument(); + }); + it('no usage when compressed', async () => { + const { getByText, queryByText } = render( + wrapInTestApp( + , + ), + ); + + expect(getByText('some-title: 15%')).toBeInTheDocument(); + expect(queryByText('usage: 10%')).toBeNull(); + }); +}); diff --git a/plugins/kubernetes/src/components/ResourceUtilization/ResourceUtilization.tsx b/plugins/kubernetes/src/components/ResourceUtilization/ResourceUtilization.tsx new file mode 100644 index 0000000000..492ec0469b --- /dev/null +++ b/plugins/kubernetes/src/components/ResourceUtilization/ResourceUtilization.tsx @@ -0,0 +1,89 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Grid, Typography } from '@material-ui/core'; + +import React from 'react'; +import { GaugePropsGetColor, LinearGauge } from '@backstage/core-components'; +import { currentToDeclaredResourceToPerc } from '../../utils/resources'; + +/** + * Context for Pod Metrics + * + * @public + */ +export interface ResourceUtilizationProps { + compressed?: boolean; + title: string; + usage: number | string; + total: number | string; + totalFormated: string; +} + +// Visible for testing +export const getProgressColor: GaugePropsGetColor = ({ + palette, + value, + inverse, + max, +}) => { + if (isNaN(value)) { + return palette.status.pending; + } + const actualMax = max ? max : 100; + const actualValue = inverse ? actualMax - value : value; + + if (actualValue >= actualMax) { + return palette.status.error; + } else if (actualValue > 90 || actualValue < 40) { + return palette.status.warning; + } + + return palette.status.ok; +}; + +/** + * Context for Pod Metrics + * + * @public + */ +export const ResourceUtilization = ({ + compressed = false, + title, + usage, + total, + totalFormated, +}: ResourceUtilizationProps) => { + const utilization = currentToDeclaredResourceToPerc(usage, total); + return ( + + + {`${title}: ${totalFormated}`} + + + + {!compressed && ( + usage: {`${utilization}%`} + )} + + + ); +}; diff --git a/plugins/kubernetes/src/components/ResourceUtilization/index.ts b/plugins/kubernetes/src/components/ResourceUtilization/index.ts new file mode 100644 index 0000000000..33d232cbf4 --- /dev/null +++ b/plugins/kubernetes/src/components/ResourceUtilization/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { ResourceUtilization } from './ResourceUtilization'; diff --git a/plugins/kubernetes/src/components/index.ts b/plugins/kubernetes/src/components/index.ts index 11550e767b..e0583a1f9e 100644 --- a/plugins/kubernetes/src/components/index.ts +++ b/plugins/kubernetes/src/components/index.ts @@ -26,3 +26,4 @@ export * from './KubernetesDrawer'; export * from './Pods'; export * from './ServicesAccordions'; export * from './KubernetesContent'; +export * from './ResourceUtilization'; diff --git a/plugins/kubernetes/src/error-detection/index.ts b/plugins/kubernetes/src/error-detection/index.ts index 67bfb70f3b..3a8789d79d 100644 --- a/plugins/kubernetes/src/error-detection/index.ts +++ b/plugins/kubernetes/src/error-detection/index.ts @@ -18,5 +18,6 @@ export type { DetectedError, DetectedErrorsByCluster, ErrorSeverity, + ResourceRef, } from './types'; export { detectErrors } from './error-detection'; diff --git a/plugins/kubernetes/src/error-detection/types.ts b/plugins/kubernetes/src/error-detection/types.ts index 7dcc6cd2f4..27dcbdd55b 100644 --- a/plugins/kubernetes/src/error-detection/types.ts +++ b/plugins/kubernetes/src/error-detection/types.ts @@ -28,6 +28,11 @@ export type ErrorSeverity = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10; */ export type DetectedErrorsByCluster = Map; +/** + * A reference to a Kubernetes object + * + * @public + */ export interface ResourceRef { name: string; namespace: string; diff --git a/plugins/kubernetes/src/hooks/PodNamesWithMetrics.ts b/plugins/kubernetes/src/hooks/PodNamesWithMetrics.ts index 66a833ad88..0df42a8ab0 100644 --- a/plugins/kubernetes/src/hooks/PodNamesWithMetrics.ts +++ b/plugins/kubernetes/src/hooks/PodNamesWithMetrics.ts @@ -16,6 +16,9 @@ import React from 'react'; import { ClientPodStatus } from '@backstage/plugin-kubernetes-common'; +/* + * @deprecated + */ export const PodNamesWithMetricsContext = React.createContext< Map >(new Map()); diff --git a/plugins/kubernetes/src/hooks/index.ts b/plugins/kubernetes/src/hooks/index.ts index c3968a19b2..12400132aa 100644 --- a/plugins/kubernetes/src/hooks/index.ts +++ b/plugins/kubernetes/src/hooks/index.ts @@ -20,3 +20,5 @@ export * from './PodNamesWithErrors'; export * from './PodNamesWithMetrics'; export * from './GroupedResponses'; export * from './Cluster'; +export * from './usePodMetrics'; +export * from './useMatchingErrors'; diff --git a/plugins/kubernetes/src/hooks/test-utils.tsx b/plugins/kubernetes/src/hooks/test-utils.tsx index a7a6f42512..a98f3e4162 100644 --- a/plugins/kubernetes/src/hooks/test-utils.tsx +++ b/plugins/kubernetes/src/hooks/test-utils.tsx @@ -17,26 +17,31 @@ import React from 'react'; import { GroupedResponsesContext } from './GroupedResponses'; import { PodNamesWithErrorsContext } from './PodNamesWithErrors'; -import { PodNamesWithMetricsContext } from './PodNamesWithMetrics'; -import { ClientPodStatus } from '@backstage/plugin-kubernetes-common'; +import { + ClientPodStatus, + ClusterAttributes, +} from '@backstage/plugin-kubernetes-common'; +import { PodMetricsContext } from './usePodMetrics'; +import { ClusterContext } from './Cluster'; export const kubernetesProviders = ( groupedResponses: any = undefined, podsWithErrors: Set = new Set(), - podNameToMetrics: Map = new Map< + podNameToMetrics: Map = new Map< string, - ClientPodStatus + ClientPodStatus[] >(), + cluster: ClusterAttributes = { name: 'some-cluster' }, ) => { return (node: React.ReactNode) => ( - <> + - + {node} - + - + ); }; diff --git a/plugins/kubernetes/src/hooks/useMatchingErrors.ts b/plugins/kubernetes/src/hooks/useMatchingErrors.ts index 623a32bdf8..aa90d52cbb 100644 --- a/plugins/kubernetes/src/hooks/useMatchingErrors.ts +++ b/plugins/kubernetes/src/hooks/useMatchingErrors.ts @@ -18,13 +18,27 @@ import { DetectedError, ResourceRef } from '../error-detection/types'; import { TypeMeta } from '@kubernetes-models/base'; import { IIoK8sApimachineryPkgApisMetaV1ObjectMeta as V1ObjectMeta } from '@kubernetes-models/apimachinery/apis/meta/v1/ObjectMeta'; +/** + * Context for detected errors + * + * @public + */ export const DetectedErrorsContext = React.createContext([]); -type Matcher = { +/** + * + * @public + */ +export type ErrorMatcher = { metadata?: V1ObjectMeta; } & TypeMeta; -export const useMatchingErrors = (matcher: Matcher): DetectedError[] => { +/** + * Find errors which match the resource + * + * @public + */ +export const useMatchingErrors = (matcher: ErrorMatcher): DetectedError[] => { const targetRef: ResourceRef = { name: matcher.metadata?.name ?? '', namespace: matcher.metadata?.namespace ?? '', diff --git a/plugins/kubernetes/src/hooks/usePodMetrics.test.tsx b/plugins/kubernetes/src/hooks/usePodMetrics.test.tsx new file mode 100644 index 0000000000..be74448abd --- /dev/null +++ b/plugins/kubernetes/src/hooks/usePodMetrics.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { PodMetricsContext, usePodMetrics } from './usePodMetrics'; +import { ClientPodStatus } from '@backstage/plugin-kubernetes-common'; + +describe('usePodMetrics', () => { + const clientPodStatus = { + pod: { + metadata: { + name: 'some-pod', + namespace: 'some-namespace', + }, + }, + cpu: {}, + memory: {}, + containers: [], + } as any; + const otherClientPodStatus = { + pod: { + metadata: { + name: 'some-other-pod', + namespace: 'some-namespace', + }, + }, + cpu: {}, + memory: {}, + containers: [], + } as any; + it('should filter non-matching ClientPodStatus', () => { + const metrics = new Map(); + metrics.set('cluster', [clientPodStatus]); + metrics.set('other-cluster', [otherClientPodStatus]); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook( + () => + usePodMetrics('cluster', { + metadata: { + name: 'some-pod', + namespace: 'some-namespace', + }, + }), + { wrapper }, + ); + expect(result.current).toStrictEqual(clientPodStatus); + }); +}); diff --git a/plugins/kubernetes/src/hooks/usePodMetrics.ts b/plugins/kubernetes/src/hooks/usePodMetrics.ts new file mode 100644 index 0000000000..4392487cac --- /dev/null +++ b/plugins/kubernetes/src/hooks/usePodMetrics.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { useContext } from 'react'; +import { IObjectMeta } from '@kubernetes-models/apimachinery/apis/meta/v1/ObjectMeta'; +import { ClientPodStatus } from '@backstage/plugin-kubernetes-common'; + +/** + * Context for Pod Metrics + * + * @public + */ +export const PodMetricsContext = React.createContext< + Map +>(new Map()); + +/* + * @alpha + */ +export type PodMetricsMatcher = { + metadata?: IObjectMeta; +}; + +/** + * Find metrics matching the provided pod + * + * @public + */ +export const usePodMetrics = ( + clusterName: string, + matcher: PodMetricsMatcher, +): ClientPodStatus | undefined => { + const targetRef = { + name: matcher.metadata?.name ?? '', + namespace: matcher.metadata?.namespace ?? '', + }; + + const metricsMap = useContext(PodMetricsContext); + + const metrics = metricsMap.get(clusterName); + + return metrics?.find(m => { + const pod = m.pod; + return ( + targetRef.name === (pod.metadata?.name ?? '') && + targetRef.namespace === (pod.metadata?.namespace ?? '') + ); + }); +}; diff --git a/plugins/kubernetes/src/utils/pod.tsx b/plugins/kubernetes/src/utils/pod.tsx index ce556fbc8d..f0517f4ec0 100644 --- a/plugins/kubernetes/src/utils/pod.tsx +++ b/plugins/kubernetes/src/utils/pod.tsx @@ -29,6 +29,7 @@ import { } from '@backstage/core-components'; import { ClientPodStatus } from '@backstage/plugin-kubernetes-common'; import { Pod } from 'kubernetes-models/v1/Pod'; +import { bytesToMiB, formatMilicores } from './resources'; export const imageChips = (pod: V1Pod): ReactNode => { const containerStatuses = pod.status?.containerStatuses ?? []; @@ -131,10 +132,6 @@ export const currentToDeclaredResourceToPerc = ( return `${(numerator * BigInt(100)) / denominator}%`; }; -const formatMilicores = (value: string | number): string => { - return `${parseFloat(value.toString()) * 1000}m`; -}; - export const podStatusToCpuUtil = (podStatus: ClientPodStatus): ReactNode => { const cpuUtil = podStatus.cpu; @@ -160,10 +157,6 @@ export const podStatusToCpuUtil = (podStatus: ClientPodStatus): ReactNode => { ); }; -const bytesToMiB = (value: string | number): string => { - return `${parseFloat(value.toString()) / 1024 / 1024}MiB`; -}; - export const podStatusToMemoryUtil = ( podStatus: ClientPodStatus, ): ReactNode => { diff --git a/plugins/kubernetes/src/utils/resources.ts b/plugins/kubernetes/src/utils/resources.ts new file mode 100644 index 0000000000..b1d8dfdc2e --- /dev/null +++ b/plugins/kubernetes/src/utils/resources.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export const currentToDeclaredResourceToPerc = ( + current: number | string, + resource: number | string, +): number => { + if (Number(resource) === 0) return 0; + + if (typeof current === 'number' && typeof resource === 'number') { + return Math.round((current / resource) * 100); + } + + const numerator: bigint = BigInt(current); + const denominator: bigint = BigInt(resource); + + return Number((numerator * BigInt(100)) / denominator); +}; + +export const bytesToMiB = (value: string | number): string => { + return `${(parseFloat(value.toString()) / 1024 / 1024).toFixed(0)}MiB`; +}; + +export const formatMilicores = (value: string | number): string => { + return `${(parseFloat(value.toString()) * 1000).toFixed(0)}m`; +};