feat: add errors to Pod Drawer (#17563)

* feat: add errors to Pod Drawer

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* fix: lint fix

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

---------

Signed-off-by: Matthew Clarke <mclarke@spotify.com>
This commit is contained in:
Matthew Clarke
2023-05-23 01:58:04 -04:00
committed by GitHub
parent 79de4c7d13
commit 4b230b9766
15 changed files with 451 additions and 104 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-kubernetes': patch
---
Add errors to PodDrawer
+1
View File
@@ -22,6 +22,7 @@ import { OAuthApi } from '@backstage/core-plugin-api';
import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common';
import { OpenIdConnectApi } from '@backstage/core-plugin-api';
import { Pod } from 'kubernetes-models/v1';
import { Pod as Pod_2 } from 'kubernetes-models/v1/Pod';
import { default as React_2 } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
import { V1ConfigMap } from '@kubernetes/client-node';
+1
View File
@@ -42,6 +42,7 @@
"@backstage/plugin-kubernetes-common": "workspace:^",
"@backstage/theme": "workspace:^",
"@kubernetes-models/apimachinery": "^1.1.0",
"@kubernetes-models/base": "^4.0.1",
"@kubernetes/client-node": "0.18.1",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
@@ -24,6 +24,7 @@ import { Cluster } from './Cluster';
import EmptyStateImage from '../assets/emptystate.svg';
import { useKubernetesObjects } from '../hooks';
import { Content, Page, Progress } from '@backstage/core-components';
import { DetectedErrorsContext } from '../hooks/useMatchingErrors';
type KubernetesContentProps = {
entity: Entity;
@@ -49,88 +50,92 @@ export const KubernetesContent = ({
: new Map<string, DetectedError[]>();
return (
<Page themeId="tool">
<Content>
{kubernetesObjects === undefined && error === undefined && <Progress />}
<DetectedErrorsContext.Provider value={[...detectedErrors.values()].flat()}>
<Page themeId="tool">
<Content>
{kubernetesObjects === undefined && error === undefined && (
<Progress />
)}
{/* errors retrieved from the kubernetes clusters */}
{clustersWithErrors.length > 0 && (
<Grid container spacing={3} direction="column">
<Grid item>
<ErrorPanel
entityName={entity.metadata.name}
clustersWithErrors={clustersWithErrors}
/>
{/* errors retrieved from the kubernetes clusters */}
{clustersWithErrors.length > 0 && (
<Grid container spacing={3} direction="column">
<Grid item>
<ErrorPanel
entityName={entity.metadata.name}
clustersWithErrors={clustersWithErrors}
/>
</Grid>
</Grid>
</Grid>
)}
)}
{/* other errors */}
{error !== undefined && (
<Grid container spacing={3} direction="column">
<Grid item>
<ErrorPanel
entityName={entity.metadata.name}
errorMessage={error}
/>
{/* other errors */}
{error !== undefined && (
<Grid container spacing={3} direction="column">
<Grid item>
<ErrorPanel
entityName={entity.metadata.name}
errorMessage={error}
/>
</Grid>
</Grid>
</Grid>
)}
)}
{kubernetesObjects && (
<Grid container spacing={3} direction="column">
<Grid item>
<ErrorReporting detectedErrors={detectedErrors} />
</Grid>
<Grid item>
<Typography variant="h3">Your Clusters</Typography>
</Grid>
<Grid item container>
{kubernetesObjects?.items.length <= 0 && (
<Grid
container
justifyContent="space-around"
direction="row"
alignItems="center"
spacing={2}
>
<Grid item xs={4}>
<Typography variant="h5">
No resources on any known clusters for{' '}
{entity.metadata.name}
</Typography>
</Grid>
<Grid item xs={4}>
<img
src={EmptyStateImage}
alt="EmptyState"
data-testid="emptyStateImg"
/>
</Grid>
</Grid>
)}
{kubernetesObjects?.items.length > 0 &&
kubernetesObjects?.items.map((item, i) => {
const podsWithErrors = new Set<string>(
detectedErrors
.get(item.cluster.name)
?.filter(de => de.sourceRef.kind === 'Pod')
.map(de => de.sourceRef.name),
);
return (
<Grid item key={i} xs={12}>
<Cluster
clusterObjects={item}
podsWithErrors={podsWithErrors}
{kubernetesObjects && (
<Grid container spacing={3} direction="column">
<Grid item>
<ErrorReporting detectedErrors={detectedErrors} />
</Grid>
<Grid item>
<Typography variant="h3">Your Clusters</Typography>
</Grid>
<Grid item container>
{kubernetesObjects?.items.length <= 0 && (
<Grid
container
justifyContent="space-around"
direction="row"
alignItems="center"
spacing={2}
>
<Grid item xs={4}>
<Typography variant="h5">
No resources on any known clusters for{' '}
{entity.metadata.name}
</Typography>
</Grid>
<Grid item xs={4}>
<img
src={EmptyStateImage}
alt="EmptyState"
data-testid="emptyStateImg"
/>
</Grid>
);
})}
</Grid>
)}
{kubernetesObjects?.items.length > 0 &&
kubernetesObjects?.items.map((item, i) => {
const podsWithErrors = new Set<string>(
detectedErrors
.get(item.cluster.name)
?.filter(de => de.sourceRef.kind === 'Pod')
.map(de => de.sourceRef.name),
);
return (
<Grid item key={i} xs={12}>
<Cluster
clusterObjects={item}
podsWithErrors={podsWithErrors}
/>
</Grid>
);
})}
</Grid>
</Grid>
</Grid>
)}
</Content>
</Page>
)}
</Content>
</Page>
</DetectedErrorsContext.Provider>
);
};
@@ -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 { render } from '@testing-library/react';
import { ErrorList } from './ErrorList';
import { Pod } from 'kubernetes-models/v1/Pod';
describe('ErrorList', () => {
it('error highlight should render', () => {
const { getByText } = render(
<ErrorList
podAndErrors={[
{
clusterName: 'some-cluster',
pod: {
metadata: {
name: 'some-pod',
namespace: 'some-namespace',
},
} as Pod,
errors: [
{
type: 'some-error',
severity: 10,
message: 'some error message',
occuranceCount: 1,
sourceRef: {
name: 'some-pod',
namespace: 'some-namespace',
kind: 'Pod',
apiGroup: 'v1',
},
proposedFix: [
{
type: 'logs',
container: 'some-container',
errorType: 'some error type',
rootCauseExplanation: 'some root cause',
possibleFixes: ['fix1', 'fix2'],
},
],
},
],
},
]}
/>,
);
expect(getByText('some-pod')).toBeInTheDocument();
expect(getByText('some error message')).toBeInTheDocument();
});
});
@@ -0,0 +1,74 @@
/*
* 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 {
List,
ListItem,
ListItemText,
Divider,
createStyles,
makeStyles,
Theme,
Paper,
} from '@material-ui/core';
import { PodAndErrors } from '../types';
const useStyles = makeStyles((_theme: Theme) =>
createStyles({
root: {
overflow: 'auto',
},
list: {
width: '100%',
},
}),
);
interface ErrorListProps {
podAndErrors: PodAndErrors[];
}
export const ErrorList = ({ podAndErrors }: ErrorListProps) => {
const classes = useStyles();
return (
<Paper className={classes.root}>
<List className={classes.list}>
{podAndErrors
.filter(pae => pae.errors.length > 0)
.flatMap(onlyPodWithErrors => {
return onlyPodWithErrors.errors.map((error, i) => {
return (
<React.Fragment
key={`${
onlyPodWithErrors.pod.metadata?.name ?? 'unknown'
}-eli-${i}`}
>
{i > 0 && <Divider key={`error-divider${i}`} />}
<ListItem>
<ListItemText
primary={error.message}
secondary={onlyPodWithErrors.pod.metadata?.name}
/>
</ListItem>
</React.Fragment>
);
});
})}
</List>
</Paper>
);
};
@@ -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 * from './ErrorList';
@@ -33,7 +33,7 @@ describe('PodDrawer', () => {
clusterName: 'some-cluster-1',
pod: {
metadata: {
name: 'ok-pod',
name: 'some-pod',
},
spec: {
containers: [
@@ -51,17 +51,40 @@ describe('PodDrawer', () => {
],
},
},
errors: [],
errors: [
{
type: 'some-error',
severity: 10,
message: 'some error message',
occuranceCount: 1,
sourceRef: {
name: 'some-pod',
namespace: 'some-namespace',
kind: 'Pod',
apiGroup: 'v1',
},
proposedFix: [
{
type: 'logs',
container: 'some-container',
errorType: 'some error type',
rootCauseExplanation: 'some root cause',
possibleFixes: ['fix1', 'fix2'],
},
],
},
],
},
} as any)}
/>,
),
);
expect(getAllByText('ok-pod')).toHaveLength(2);
expect(getAllByText('some-pod')).toHaveLength(3);
expect(getByText('Pod (127.0.0.1)')).toBeInTheDocument();
expect(getByText('YAML')).toBeInTheDocument();
expect(getByText('Containers')).toBeInTheDocument();
expect(getByText('some-container')).toBeInTheDocument();
expect(getByText('some error message')).toBeInTheDocument();
});
});
@@ -32,6 +32,7 @@ import { ContainerCard } from './ContainerCard';
import { PodAndErrors } from '../types';
import { KubernetesDrawer } from '../../KubernetesDrawer';
import { PendingPodContent } from './PendingPodContent';
import { ErrorList } from '../ErrorList';
const useDrawerContentStyles = makeStyles((_theme: Theme) =>
createStyles({
@@ -116,6 +117,16 @@ export const PodDrawer = ({ podAndErrors, open }: PodDrawerProps) => {
)}
</ItemCardGrid>
</Grid>
{podAndErrors.errors.length > 0 && (
<Grid item xs={12}>
<Typography variant="h5">Errors:</Typography>
</Grid>
)}
{podAndErrors.errors.length > 0 && (
<Grid item xs={12}>
<ErrorList podAndErrors={[podAndErrors]} />
</Grid>
)}
</Grid>
)}
</div>
+33 -22
View File
@@ -15,7 +15,6 @@
*/
import React, { useContext } from 'react';
import { V1Pod } from '@kubernetes/client-node';
import { PodDrawer } from './PodDrawer';
import {
containersReady,
@@ -27,18 +26,21 @@ import {
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';
export const READY_COLUMNS: PodColumns = 'READY';
export const RESOURCE_COLUMNS: PodColumns = 'RESOURCE';
export type PodColumns = 'READY' | 'RESOURCE';
type PodsTablesProps = {
pods: V1Pod[];
pods: Pod | V1Pod[];
extraColumns?: PodColumns[];
children?: React.ReactNode;
};
const READY: TableColumn<V1Pod>[] = [
const READY: TableColumn<Pod>[] = [
{
title: 'containers ready',
align: 'center',
@@ -54,26 +56,37 @@ const READY: TableColumn<V1Pod>[] = [
},
];
const PodDrawerTrigger = ({ pod }: { pod: Pod }) => {
const cluster = useContext(ClusterContext);
const errors = useMatchingErrors({
kind: 'Pod',
apiVersion: 'v1',
metadata: pod.metadata,
});
return (
<PodDrawer
podAndErrors={{
pod: pod as any,
clusterName: cluster.name,
errors: errors,
}}
/>
);
};
export const PodsTable = ({ pods, extraColumns = [] }: PodsTablesProps) => {
const podNamesWithMetrics = useContext(PodNamesWithMetricsContext);
const cluster = useContext(ClusterContext);
const defaultColumns: TableColumn<V1Pod>[] = [
const defaultColumns: TableColumn<Pod>[] = [
{
title: 'name',
highlight: true,
render: (pod: V1Pod) => (
<PodDrawer
podAndErrors={{
pod: pod as any,
clusterName: cluster.name,
errors: [],
}}
/>
),
render: (pod: Pod) => {
return <PodDrawerTrigger pod={pod} />;
},
},
{
title: 'phase',
render: (pod: V1Pod) => pod.status?.phase ?? 'unknown',
render: (pod: Pod) => pod.status?.phase ?? 'unknown',
width: 'auto',
},
{
@@ -81,16 +94,16 @@ export const PodsTable = ({ pods, extraColumns = [] }: PodsTablesProps) => {
render: containerStatuses,
},
];
const columns: TableColumn<V1Pod>[] = [...defaultColumns];
const columns: TableColumn<Pod>[] = [...defaultColumns];
if (extraColumns.includes(READY_COLUMNS)) {
columns.push(...READY);
}
if (extraColumns.includes(RESOURCE_COLUMNS)) {
const resourceColumns: TableColumn<V1Pod>[] = [
const resourceColumns: TableColumn<Pod>[] = [
{
title: 'CPU usage %',
render: (pod: V1Pod) => {
render: (pod: Pod) => {
const metrics = podNamesWithMetrics.get(pod.metadata?.name ?? '');
if (!metrics) {
@@ -103,7 +116,7 @@ export const PodsTable = ({ pods, extraColumns = [] }: PodsTablesProps) => {
},
{
title: 'Memory usage %',
render: (pod: V1Pod) => {
render: (pod: Pod) => {
const metrics = podNamesWithMetrics.get(pod.metadata?.name ?? '');
if (!metrics) {
@@ -123,13 +136,11 @@ export const PodsTable = ({ pods, extraColumns = [] }: PodsTablesProps) => {
width: '100%',
};
const usePods = pods.map(p => ({ ...p, id: p.metadata?.uid }));
return (
<div style={tableStyle}>
<Table
options={{ paging: true, search: false, emptyRowsWhenPaging: false }}
data={usePods}
data={pods as Pod[]}
columns={columns}
/>
</div>
+2 -1
View File
@@ -14,9 +14,10 @@
* limitations under the License.
*/
import { Pod } from 'kubernetes-models/v1';
import { DetectedError } from '../../error-detection';
export interface PodAndErrors {
clusterName: string;
pod: Pod;
errors: any[];
errors: DetectedError[];
}
@@ -0,0 +1,85 @@
/*
* 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 { DetectedErrorsContext, useMatchingErrors } from './useMatchingErrors';
import { DetectedError } from '../error-detection';
import { ResourceRef } from '../error-detection/types';
const genericErrorWithRef = (resourceRef: ResourceRef): DetectedError => {
return {
type: 'some-error',
severity: 10,
message: 'some error message',
occuranceCount: 1,
sourceRef: resourceRef,
proposedFix: [
{
type: 'logs',
container: 'some-container',
errorType: 'some error type',
rootCauseExplanation: 'some root cause',
possibleFixes: ['fix1', 'fix2'],
},
],
};
};
describe('useMatchingErrors', () => {
it('should filter non-matching resources', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<DetectedErrorsContext.Provider
value={[
genericErrorWithRef({
name: 'some-other-pod',
namespace: 'some-namespace',
kind: 'some-kind',
apiGroup: 'some-apiGroup',
}),
genericErrorWithRef({
name: 'some-name',
namespace: 'some-namespace',
kind: 'some-kind',
apiGroup: 'some-apiGroup',
}),
]}
>
{children}
</DetectedErrorsContext.Provider>
);
const { result } = renderHook(
() =>
useMatchingErrors({
metadata: {
name: 'some-name',
namespace: 'some-namespace',
},
kind: 'some-kind',
apiVersion: 'some-apiGroup',
}),
{ wrapper },
);
expect(result.current).toStrictEqual([
genericErrorWithRef({
name: 'some-name',
namespace: 'some-namespace',
kind: 'some-kind',
apiGroup: 'some-apiGroup',
}),
]);
});
});
@@ -0,0 +1,46 @@
/*
* 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 { DetectedError, ResourceRef } from '../error-detection/types';
import { TypeMeta } from '@kubernetes-models/base';
import { IIoK8sApimachineryPkgApisMetaV1ObjectMeta as V1ObjectMeta } from '@kubernetes-models/apimachinery/apis/meta/v1/ObjectMeta';
export const DetectedErrorsContext = React.createContext<DetectedError[]>([]);
type Matcher = {
metadata?: V1ObjectMeta;
} & TypeMeta;
export const useMatchingErrors = (matcher: Matcher): DetectedError[] => {
const targetRef: ResourceRef = {
name: matcher.metadata?.name ?? '',
namespace: matcher.metadata?.namespace ?? '',
kind: matcher.kind,
apiGroup: matcher.apiVersion,
};
const errors = useContext(DetectedErrorsContext);
return errors.filter(e => {
const r = e.sourceRef;
return (
targetRef.apiGroup === r.apiGroup &&
targetRef.kind === r.kind &&
targetRef.name === r.name &&
targetRef.namespace === r.namespace
);
});
};
+4 -3
View File
@@ -28,6 +28,7 @@ import {
SubvalueCell,
} from '@backstage/core-components';
import { ClientPodStatus } from '@backstage/plugin-kubernetes-common';
import { Pod } from 'kubernetes-models/v1/Pod';
export const imageChips = (pod: V1Pod): ReactNode => {
const containerStatuses = pod.status?.containerStatuses ?? [];
@@ -38,19 +39,19 @@ export const imageChips = (pod: V1Pod): ReactNode => {
return <div>{images}</div>;
};
export const containersReady = (pod: V1Pod): string => {
export const containersReady = (pod: Pod): string => {
const containerStatuses = pod.status?.containerStatuses ?? [];
const containersReadyItem = containerStatuses.filter(cs => cs.ready).length;
return `${containersReadyItem}/${containerStatuses.length}`;
};
export const totalRestarts = (pod: V1Pod): number => {
export const totalRestarts = (pod: Pod): number => {
const containerStatuses = pod.status?.containerStatuses ?? [];
return containerStatuses?.reduce((a, b) => a + b.restartCount, 0);
};
export const containerStatuses = (pod: V1Pod): ReactNode => {
export const containerStatuses = (pod: Pod): ReactNode => {
const containerStatusesItem = pod.status?.containerStatuses ?? [];
const errors = containerStatusesItem.reduce((accum, next) => {
if (next.state === undefined) {
+1
View File
@@ -7357,6 +7357,7 @@ __metadata:
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@kubernetes-models/apimachinery": ^1.1.0
"@kubernetes-models/base": ^4.0.1
"@kubernetes/client-node": 0.18.1
"@material-ui/core": ^4.12.2
"@material-ui/icons": ^4.9.1