diff --git a/.changeset/selfish-mugs-study.md b/.changeset/selfish-mugs-study.md new file mode 100644 index 0000000000..0a2152aecc --- /dev/null +++ b/.changeset/selfish-mugs-study.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes': patch +--- + +Add errors to PodDrawer diff --git a/plugins/kubernetes/api-report.md b/plugins/kubernetes/api-report.md index 5529b7910f..823b9c89a2 100644 --- a/plugins/kubernetes/api-report.md +++ b/plugins/kubernetes/api-report.md @@ -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'; diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index e6a0b518a1..d0d32814a1 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -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", diff --git a/plugins/kubernetes/src/components/KubernetesContent.tsx b/plugins/kubernetes/src/components/KubernetesContent.tsx index 63aa52f1ba..a465422095 100644 --- a/plugins/kubernetes/src/components/KubernetesContent.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent.tsx @@ -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(); return ( - - - {kubernetesObjects === undefined && error === undefined && } + + + + {kubernetesObjects === undefined && error === undefined && ( + + )} - {/* errors retrieved from the kubernetes clusters */} - {clustersWithErrors.length > 0 && ( - - - + {/* errors retrieved from the kubernetes clusters */} + {clustersWithErrors.length > 0 && ( + + + + - - )} + )} - {/* other errors */} - {error !== undefined && ( - - - + {/* other errors */} + {error !== undefined && ( + + + + - - )} + )} - {kubernetesObjects && ( - - - - - - Your Clusters - - - {kubernetesObjects?.items.length <= 0 && ( - - - - No resources on any known clusters for{' '} - {entity.metadata.name} - - - - EmptyState - - - )} - {kubernetesObjects?.items.length > 0 && - kubernetesObjects?.items.map((item, i) => { - const podsWithErrors = new Set( - detectedErrors - .get(item.cluster.name) - ?.filter(de => de.sourceRef.kind === 'Pod') - .map(de => de.sourceRef.name), - ); - - return ( - - + + + + + Your Clusters + + + {kubernetesObjects?.items.length <= 0 && ( + + + + No resources on any known clusters for{' '} + {entity.metadata.name} + + + + EmptyState - ); - })} + + )} + {kubernetesObjects?.items.length > 0 && + kubernetesObjects?.items.map((item, i) => { + const podsWithErrors = new Set( + detectedErrors + .get(item.cluster.name) + ?.filter(de => de.sourceRef.kind === 'Pod') + .map(de => de.sourceRef.name), + ); + + return ( + + + + ); + })} + - - )} - - + )} + + + ); }; diff --git a/plugins/kubernetes/src/components/Pods/ErrorList/ErrorList.test.tsx b/plugins/kubernetes/src/components/Pods/ErrorList/ErrorList.test.tsx new file mode 100644 index 0000000000..f28580b509 --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/ErrorList/ErrorList.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 { 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( + , + ); + expect(getByText('some-pod')).toBeInTheDocument(); + expect(getByText('some error message')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/Pods/ErrorList/ErrorList.tsx b/plugins/kubernetes/src/components/Pods/ErrorList/ErrorList.tsx new file mode 100644 index 0000000000..b7b3418889 --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/ErrorList/ErrorList.tsx @@ -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 ( + + + {podAndErrors + .filter(pae => pae.errors.length > 0) + .flatMap(onlyPodWithErrors => { + return onlyPodWithErrors.errors.map((error, i) => { + return ( + + {i > 0 && } + + + + + ); + }); + })} + + + ); +}; diff --git a/plugins/kubernetes/src/components/Pods/ErrorList/index.ts b/plugins/kubernetes/src/components/Pods/ErrorList/index.ts new file mode 100644 index 0000000000..f40c0583c9 --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/ErrorList/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 * from './ErrorList'; diff --git a/plugins/kubernetes/src/components/Pods/PodDrawer/PodDrawer.test.tsx b/plugins/kubernetes/src/components/Pods/PodDrawer/PodDrawer.test.tsx index fa4f9f7168..64fd38c8d7 100644 --- a/plugins/kubernetes/src/components/Pods/PodDrawer/PodDrawer.test.tsx +++ b/plugins/kubernetes/src/components/Pods/PodDrawer/PodDrawer.test.tsx @@ -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(); }); }); diff --git a/plugins/kubernetes/src/components/Pods/PodDrawer/PodDrawer.tsx b/plugins/kubernetes/src/components/Pods/PodDrawer/PodDrawer.tsx index a78f08683c..5e49410dd4 100644 --- a/plugins/kubernetes/src/components/Pods/PodDrawer/PodDrawer.tsx +++ b/plugins/kubernetes/src/components/Pods/PodDrawer/PodDrawer.tsx @@ -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) => { )} + {podAndErrors.errors.length > 0 && ( + + Errors: + + )} + {podAndErrors.errors.length > 0 && ( + + + + )} )} diff --git a/plugins/kubernetes/src/components/Pods/PodsTable.tsx b/plugins/kubernetes/src/components/Pods/PodsTable.tsx index 4b7b4b93ed..620e91ce72 100644 --- a/plugins/kubernetes/src/components/Pods/PodsTable.tsx +++ b/plugins/kubernetes/src/components/Pods/PodsTable.tsx @@ -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[] = [ +const READY: TableColumn[] = [ { title: 'containers ready', align: 'center', @@ -54,26 +56,37 @@ const READY: TableColumn[] = [ }, ]; +const PodDrawerTrigger = ({ pod }: { pod: Pod }) => { + const cluster = useContext(ClusterContext); + const errors = useMatchingErrors({ + kind: 'Pod', + apiVersion: 'v1', + metadata: pod.metadata, + }); + return ( + + ); +}; + export const PodsTable = ({ pods, extraColumns = [] }: PodsTablesProps) => { const podNamesWithMetrics = useContext(PodNamesWithMetricsContext); - const cluster = useContext(ClusterContext); - const defaultColumns: TableColumn[] = [ + const defaultColumns: TableColumn[] = [ { title: 'name', highlight: true, - render: (pod: V1Pod) => ( - - ), + render: (pod: Pod) => { + return ; + }, }, { 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[] = [...defaultColumns]; + const columns: TableColumn[] = [...defaultColumns]; if (extraColumns.includes(READY_COLUMNS)) { columns.push(...READY); } if (extraColumns.includes(RESOURCE_COLUMNS)) { - const resourceColumns: TableColumn[] = [ + const resourceColumns: TableColumn[] = [ { 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 (
diff --git a/plugins/kubernetes/src/components/Pods/types.ts b/plugins/kubernetes/src/components/Pods/types.ts index 195f0b9135..ac707c2a57 100644 --- a/plugins/kubernetes/src/components/Pods/types.ts +++ b/plugins/kubernetes/src/components/Pods/types.ts @@ -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[]; } diff --git a/plugins/kubernetes/src/hooks/useMatchingErrors.test.tsx b/plugins/kubernetes/src/hooks/useMatchingErrors.test.tsx new file mode 100644 index 0000000000..b5fe588e86 --- /dev/null +++ b/plugins/kubernetes/src/hooks/useMatchingErrors.test.tsx @@ -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 }) => ( + + {children} + + ); + + 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', + }), + ]); + }); +}); diff --git a/plugins/kubernetes/src/hooks/useMatchingErrors.ts b/plugins/kubernetes/src/hooks/useMatchingErrors.ts new file mode 100644 index 0000000000..623a32bdf8 --- /dev/null +++ b/plugins/kubernetes/src/hooks/useMatchingErrors.ts @@ -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([]); + +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 + ); + }); +}; diff --git a/plugins/kubernetes/src/utils/pod.tsx b/plugins/kubernetes/src/utils/pod.tsx index 2f657e7658..ce556fbc8d 100644 --- a/plugins/kubernetes/src/utils/pod.tsx +++ b/plugins/kubernetes/src/utils/pod.tsx @@ -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
{images}
; }; -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) { diff --git a/yarn.lock b/yarn.lock index 632d538a16..66b27be455 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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