feat: Add resource util pod drawer (#18169)
* feat: add resource utilization to pod drawer Signed-off-by: Matthew Clarke <mclarke@spotify.com> * fix: lint Signed-off-by: Matthew Clarke <mclarke@spotify.com> * chore: api report Signed-off-by: Matthew Clarke <mclarke@spotify.com> * chore: api reports Signed-off-by: Matthew Clarke <mclarke@spotify.com> * test: fix tests Signed-off-by: Matthew Clarke <mclarke@spotify.com> * test: tests Signed-off-by: Matthew Clarke <mclarke@spotify.com> --------- Signed-off-by: Matthew Clarke <mclarke@spotify.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
'@backstage/plugin-kubernetes': patch
|
||||
---
|
||||
|
||||
Add resource utilization to Pod Drawer
|
||||
@@ -34,4 +34,28 @@ describe('<LinearGauge />', () => {
|
||||
const { getByTitle } = await renderInTestApp(<LinearGauge value={1.5} />);
|
||||
expect(getByTitle('100%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders thick', async () => {
|
||||
const { container, getByTitle } = await renderInTestApp(
|
||||
<LinearGauge value={0.5} width="thick" />,
|
||||
);
|
||||
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(
|
||||
<LinearGauge value={0.5} width="thin" />,
|
||||
);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<BackstageTheme>();
|
||||
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) {
|
||||
<Typography component="span">
|
||||
<Line
|
||||
percent={percent}
|
||||
strokeWidth={4}
|
||||
trailWidth={4}
|
||||
strokeWidth={lineWidth}
|
||||
trailWidth={lineWidth}
|
||||
strokeColor={strokeColor}
|
||||
/>
|
||||
</Typography>
|
||||
|
||||
@@ -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<ContainerCardProps>;
|
||||
|
||||
// @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<string, DetectedError[]>;
|
||||
|
||||
// @public
|
||||
export const DetectedErrorsContext: React_2.Context<DetectedError[]>;
|
||||
|
||||
// @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<Map<string, ClientPodStatus[]>>;
|
||||
|
||||
// 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;
|
||||
```
|
||||
|
||||
@@ -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<string, ClientPodStatus>());
|
||||
|
||||
const podMetricsMap = new Map<string, ClientPodStatus[]>();
|
||||
podMetricsMap.set(clusterObjects.cluster.name, clusterObjects.podMetrics);
|
||||
return (
|
||||
<ClusterContext.Provider value={clusterObjects.cluster}>
|
||||
<GroupedResponsesContext.Provider value={groupedResponses}>
|
||||
<PodNamesWithMetricsContext.Provider value={podNameToMetrics}>
|
||||
<PodMetricsContext.Provider value={podMetricsMap}>
|
||||
<PodNamesWithErrorsContext.Provider value={podsWithErrors}>
|
||||
<Accordion TransitionProps={{ unmountOnExit: true }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
@@ -169,7 +163,7 @@ export const Cluster = ({ clusterObjects, podsWithErrors }: ClusterProps) => {
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</PodNamesWithErrorsContext.Provider>
|
||||
</PodNamesWithMetricsContext.Provider>
|
||||
</PodMetricsContext.Provider>
|
||||
</GroupedResponsesContext.Provider>
|
||||
</ClusterContext.Provider>
|
||||
);
|
||||
|
||||
@@ -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<ContainerCardProps> = ({
|
||||
podScope,
|
||||
containerSpec,
|
||||
containerStatus,
|
||||
containerMetrics,
|
||||
}: ContainerCardProps) => {
|
||||
// This should never be undefined
|
||||
if (containerSpec === undefined) {
|
||||
@@ -168,6 +173,53 @@ export const ContainerCard: React.FC<ContainerCardProps> = ({
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
{containerMetrics && (
|
||||
<Grid container item xs={12} spacing={0}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle1">
|
||||
Resource utilization
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} style={{ minHeight: '5rem' }}>
|
||||
<ResourceUtilization
|
||||
compressed
|
||||
title="CPU requests"
|
||||
usage={containerMetrics.cpuUsage.currentUsage}
|
||||
total={containerMetrics.cpuUsage.requestTotal}
|
||||
totalFormated={formatMilicores(
|
||||
containerMetrics.cpuUsage.requestTotal,
|
||||
)}
|
||||
/>
|
||||
<ResourceUtilization
|
||||
compressed
|
||||
title="CPU limits"
|
||||
usage={containerMetrics.cpuUsage.currentUsage}
|
||||
total={containerMetrics.cpuUsage.limitTotal}
|
||||
totalFormated={formatMilicores(
|
||||
containerMetrics.cpuUsage.limitTotal,
|
||||
)}
|
||||
/>
|
||||
<ResourceUtilization
|
||||
compressed
|
||||
title="Memory requests"
|
||||
usage={containerMetrics.memoryUsage.currentUsage}
|
||||
total={containerMetrics.memoryUsage.requestTotal}
|
||||
totalFormated={bytesToMiB(
|
||||
containerMetrics.memoryUsage.requestTotal,
|
||||
)}
|
||||
/>
|
||||
<ResourceUtilization
|
||||
compressed
|
||||
title="Memory limits"
|
||||
usage={containerMetrics.memoryUsage.currentUsage}
|
||||
total={containerMetrics.memoryUsage.limitTotal}
|
||||
totalFormated={bytesToMiB(
|
||||
containerMetrics.memoryUsage.requestTotal,
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</CardContent>
|
||||
<CardActions disableSpacing>
|
||||
|
||||
@@ -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 (
|
||||
<KubernetesDrawer
|
||||
@@ -95,6 +99,41 @@ export const PodDrawer = ({ podAndErrors, open }: PodDrawerProps) => {
|
||||
}
|
||||
>
|
||||
<div className={classes.content}>
|
||||
{podMetrics && (
|
||||
<Grid container item xs={12}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h5">Resource utilization</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<ResourceUtilization
|
||||
title="CPU requests"
|
||||
usage={podMetrics.cpu.currentUsage}
|
||||
total={podMetrics.cpu.requestTotal}
|
||||
totalFormated={formatMilicores(podMetrics.cpu.requestTotal)}
|
||||
/>
|
||||
<ResourceUtilization
|
||||
title="CPU limits"
|
||||
usage={podMetrics.cpu.currentUsage}
|
||||
total={podMetrics.cpu.limitTotal}
|
||||
totalFormated={formatMilicores(podMetrics.cpu.limitTotal)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<ResourceUtilization
|
||||
title="Memory requests"
|
||||
usage={podMetrics.memory.currentUsage}
|
||||
total={podMetrics.memory.requestTotal}
|
||||
totalFormated={bytesToMiB(podMetrics.memory.requestTotal)}
|
||||
/>
|
||||
<ResourceUtilization
|
||||
title="Memory limits"
|
||||
usage={podMetrics.memory.currentUsage}
|
||||
total={podMetrics.memory.limitTotal}
|
||||
totalFormated={bytesToMiB(podMetrics.memory.requestTotal)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
{podAndErrors.pod.status?.phase === 'Pending' && (
|
||||
<PendingPodContent pod={podAndErrors.pod} />
|
||||
)}
|
||||
@@ -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 (
|
||||
<ContainerCard
|
||||
key={`container-card-${podAndErrors.pod.metadata?.name}-${i}`}
|
||||
containerMetrics={containerMetrics}
|
||||
podScope={{
|
||||
podName: podAndErrors.pod.metadata?.name ?? 'unknown',
|
||||
podNamespace:
|
||||
|
||||
+25
-14
@@ -62,25 +62,36 @@ describe('PodsTable', () => {
|
||||
expect(getByText('OK')).toBeInTheDocument();
|
||||
});
|
||||
it('should render pod, with metrics context', async () => {
|
||||
const podNameToClientPodStatus = new Map<string, ClientPodStatus>();
|
||||
const clusterToClientPodStatus = new Map<string, ClientPodStatus[]>();
|
||||
|
||||
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<string, ClientPodStatus>();
|
||||
const podNameToClientPodStatus = new Map<string, ClientPodStatus[]>();
|
||||
|
||||
const wrapper = kubernetesProviders(
|
||||
undefined,
|
||||
|
||||
+25
-16
@@ -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 <Typography>unknown</Typography>;
|
||||
}
|
||||
|
||||
return <>{podStatusToCpuUtil(metrics)}</>;
|
||||
};
|
||||
|
||||
const Memory = ({ clusterName, pod }: { clusterName: string; pod: Pod }) => {
|
||||
const metrics = usePodMetrics(clusterName, pod);
|
||||
|
||||
if (!metrics) {
|
||||
return <Typography>unknown</Typography>;
|
||||
}
|
||||
|
||||
return <>{podStatusToMemoryUtil(metrics)}</>;
|
||||
};
|
||||
|
||||
export const PodsTable = ({ pods, extraColumns = [] }: PodsTablesProps) => {
|
||||
const podNamesWithMetrics = useContext(PodNamesWithMetricsContext);
|
||||
const cluster = useContext(ClusterContext);
|
||||
const defaultColumns: TableColumn<Pod>[] = [
|
||||
{
|
||||
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 <Cpu clusterName={cluster.name} pod={pod} />;
|
||||
},
|
||||
width: 'auto',
|
||||
},
|
||||
{
|
||||
title: 'Memory usage %',
|
||||
render: (pod: Pod) => {
|
||||
const metrics = podNamesWithMetrics.get(pod.metadata?.name ?? '');
|
||||
|
||||
if (!metrics) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
return podStatusToMemoryUtil(metrics);
|
||||
return <Memory clusterName={cluster.name} pod={pod} />;
|
||||
},
|
||||
width: 'auto',
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
<ResourceUtilization
|
||||
title="some-title"
|
||||
usage="1000"
|
||||
total="10000"
|
||||
totalFormated="15%"
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getByText('some-title: 15%')).toBeInTheDocument();
|
||||
expect(getByText('usage: 10%')).toBeInTheDocument();
|
||||
});
|
||||
it('no usage when compressed', async () => {
|
||||
const { getByText, queryByText } = render(
|
||||
wrapInTestApp(
|
||||
<ResourceUtilization
|
||||
compressed
|
||||
title="some-title"
|
||||
usage="1000"
|
||||
total="10000"
|
||||
totalFormated="15%"
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getByText('some-title: 15%')).toBeInTheDocument();
|
||||
expect(queryByText('usage: 10%')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Grid container spacing={0}>
|
||||
<Grid item xs={12}>
|
||||
<Typography
|
||||
variant={compressed ? 'caption' : 'subtitle2'}
|
||||
>{`${title}: ${totalFormated}`}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<LinearGauge
|
||||
getColor={getProgressColor}
|
||||
width={compressed ? 'thin' : 'thick'}
|
||||
value={utilization / 100}
|
||||
/>
|
||||
{!compressed && (
|
||||
<Typography variant="caption">usage: {`${utilization}%`}</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -26,3 +26,4 @@ export * from './KubernetesDrawer';
|
||||
export * from './Pods';
|
||||
export * from './ServicesAccordions';
|
||||
export * from './KubernetesContent';
|
||||
export * from './ResourceUtilization';
|
||||
|
||||
@@ -18,5 +18,6 @@ export type {
|
||||
DetectedError,
|
||||
DetectedErrorsByCluster,
|
||||
ErrorSeverity,
|
||||
ResourceRef,
|
||||
} from './types';
|
||||
export { detectErrors } from './error-detection';
|
||||
|
||||
@@ -28,6 +28,11 @@ export type ErrorSeverity = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
|
||||
*/
|
||||
export type DetectedErrorsByCluster = Map<string, DetectedError[]>;
|
||||
|
||||
/**
|
||||
* A reference to a Kubernetes object
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface ResourceRef {
|
||||
name: string;
|
||||
namespace: string;
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
import React from 'react';
|
||||
import { ClientPodStatus } from '@backstage/plugin-kubernetes-common';
|
||||
|
||||
/*
|
||||
* @deprecated
|
||||
*/
|
||||
export const PodNamesWithMetricsContext = React.createContext<
|
||||
Map<string, ClientPodStatus>
|
||||
>(new Map<string, ClientPodStatus>());
|
||||
|
||||
@@ -20,3 +20,5 @@ export * from './PodNamesWithErrors';
|
||||
export * from './PodNamesWithMetrics';
|
||||
export * from './GroupedResponses';
|
||||
export * from './Cluster';
|
||||
export * from './usePodMetrics';
|
||||
export * from './useMatchingErrors';
|
||||
|
||||
@@ -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<string> = new Set<string>(),
|
||||
podNameToMetrics: Map<string, ClientPodStatus> = new Map<
|
||||
podNameToMetrics: Map<string, ClientPodStatus[]> = new Map<
|
||||
string,
|
||||
ClientPodStatus
|
||||
ClientPodStatus[]
|
||||
>(),
|
||||
cluster: ClusterAttributes = { name: 'some-cluster' },
|
||||
) => {
|
||||
return (node: React.ReactNode) => (
|
||||
<>
|
||||
<ClusterContext.Provider value={cluster}>
|
||||
<GroupedResponsesContext.Provider value={groupedResponses}>
|
||||
<PodNamesWithMetricsContext.Provider value={podNameToMetrics}>
|
||||
<PodMetricsContext.Provider value={podNameToMetrics}>
|
||||
<PodNamesWithErrorsContext.Provider value={podsWithErrors}>
|
||||
{node}
|
||||
</PodNamesWithErrorsContext.Provider>
|
||||
</PodNamesWithMetricsContext.Provider>
|
||||
</PodMetricsContext.Provider>
|
||||
</GroupedResponsesContext.Provider>
|
||||
</>
|
||||
</ClusterContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<DetectedError[]>([]);
|
||||
|
||||
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 ?? '',
|
||||
|
||||
@@ -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<string, ClientPodStatus[]>();
|
||||
metrics.set('cluster', [clientPodStatus]);
|
||||
metrics.set('other-cluster', [otherClientPodStatus]);
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<PodMetricsContext.Provider value={metrics}>
|
||||
{children}
|
||||
</PodMetricsContext.Provider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
usePodMetrics('cluster', {
|
||||
metadata: {
|
||||
name: 'some-pod',
|
||||
namespace: 'some-namespace',
|
||||
},
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
expect(result.current).toStrictEqual(clientPodStatus);
|
||||
});
|
||||
});
|
||||
@@ -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<string, ClientPodStatus[]>
|
||||
>(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 ?? '')
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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`;
|
||||
};
|
||||
Reference in New Issue
Block a user