diff --git a/.changeset/wise-bats-repair.md b/.changeset/wise-bats-repair.md new file mode 100644 index 0000000000..fc383eb11d --- /dev/null +++ b/.changeset/wise-bats-repair.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +'@backstage/plugin-kubernetes': patch +--- + +Add proposed fix dialog for pod errors diff --git a/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml b/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml index a1a2e105b3..8f225e666a 100644 --- a/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml +++ b/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml @@ -32,6 +32,42 @@ spec: ports: - containerPort: 80 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dice-roller-incorrect-probe + labels: + 'backstage.io/kubernetes-id': dice-roller +spec: + selector: + matchLabels: + app: dice-roller-incorrect-probe + replicas: 2 + template: + metadata: + labels: + app: dice-roller-incorrect-probe + 'backstage.io/kubernetes-id': dice-roller + spec: + containers: + - name: nginx + image: nginx:1.14.2 + resources: + requests: + memory: '64Mi' + cpu: '50m' + limits: + memory: '128Mi' + cpu: '50m' + readinessProbe: + tcpSocket: + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + ports: + - containerPort: 80 + --- apiVersion: apps/v1 kind: Deployment diff --git a/plugins/kubernetes/api-report.md b/plugins/kubernetes/api-report.md index 823b9c89a2..e117e3627d 100644 --- a/plugins/kubernetes/api-report.md +++ b/plugins/kubernetes/api-report.md @@ -6,6 +6,7 @@ /// import { ApiRef } from '@backstage/core-plugin-api'; +import { AsyncState } from 'react-use/lib/useAsyncFn'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { ClientPodStatus } from '@backstage/plugin-kubernetes-common'; import { ClusterAttributes } from '@backstage/plugin-kubernetes-common'; @@ -14,6 +15,9 @@ import { CustomObjectsByEntityRequest } from '@backstage/plugin-kubernetes-commo import { CustomResourceMatcher } from '@backstage/plugin-kubernetes-common'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; +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 { IObjectMeta } from '@kubernetes-models/apimachinery/apis/meta/v1/ObjectMeta'; import type { JsonObject } from '@backstage/types'; @@ -21,8 +25,8 @@ import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; 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 { 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 { V1ConfigMap } from '@kubernetes/client-node'; @@ -78,6 +82,25 @@ export interface ClusterLinksFormatterOptions { // @public (undocumented) export const clusterLinksFormatters: Record; +// @public +export const ContainerCard: React_2.FC; + +// @public +export interface ContainerCardProps { + // (undocumented) + containerSpec?: IContainer; + // (undocumented) + containerStatus: IContainerStatus; + // (undocumented) + podScope: PodScope; +} + +// @public +export interface ContainerScope extends PodScope { + // (undocumented) + containerName: string; +} + // Warning: (ae-forgotten-export) The symbol "CronJobsAccordionsProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "CronJobsAccordions" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -113,7 +136,7 @@ export interface DetectedError { // Warning: (ae-forgotten-export) The symbol "ProposedFix" needs to be exported by the entry point index.d.ts // // (undocumented) - proposedFix: ProposedFix[]; + proposedFix?: ProposedFix; // (undocumented) severity: ErrorSeverity; // Warning: (ae-forgotten-export) The symbol "ResourceRef" needs to be exported by the entry point index.d.ts @@ -144,6 +167,15 @@ export type EntityKubernetesContentProps = { refreshIntervalMs?: number; }; +// @public +export const ErrorList: ({ podAndErrors }: ErrorListProps) => JSX.Element; + +// @public +export interface ErrorListProps { + // (undocumented) + podAndErrors: PodAndErrors[]; +} + // 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) // @@ -165,6 +197,65 @@ export const ErrorReporting: ({ // @public export type ErrorSeverity = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10; +// @public +export const Events: ({ + involvedObjectName, + namespace, + clusterName, + warningEventsOnly, +}: EventsProps) => JSX.Element; + +// @public +export const EventsContent: ({ + events, + warningEventsOnly, +}: EventsContentProps) => JSX.Element; + +// @public +export interface EventsContentProps { + // (undocumented) + events: Event_2[]; + // (undocumented) + warningEventsOnly?: boolean; +} + +// @public +export interface EventsOptions { + // (undocumented) + clusterName: string; + // (undocumented) + involvedObjectName: string; + // (undocumented) + namespace: string; +} + +// @public +export interface EventsProps { + // (undocumented) + clusterName: string; + // (undocumented) + involvedObjectName: string; + // (undocumented) + namespace: string; + // (undocumented) + warningEventsOnly?: boolean; +} + +// @public +export const FixDialog: React_2.FC; + +// @public +export interface FixDialogProps { + // (undocumented) + clusterName: string; + // (undocumented) + error: DetectedError; + // (undocumented) + open?: boolean; + // (undocumented) + pod: Pod; +} + // Warning: (ae-forgotten-export) The symbol "FormatClusterLinkOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "formatClusterLink" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -416,12 +507,19 @@ export { kubernetesPlugin as plugin }; // // @public (undocumented) export interface KubernetesProxyApi { + // (undocumented) + getEventsByInvolvedObjectName(request: { + clusterName: string; + involvedObjectName: string; + namespace: string; + }): Promise; // (undocumented) getPodLogs(request: { podName: string; namespace: string; clusterName: string; containerName: string; + previous?: boolean; }): Promise<{ text: string; }>; @@ -436,16 +534,28 @@ export const kubernetesProxyApiRef: ApiRef; export class KubernetesProxyClient { constructor(options: { kubernetesApi: KubernetesApi }); // (undocumented) + getEventsByInvolvedObjectName({ + clusterName, + involvedObjectName, + namespace, + }: { + clusterName: string; + involvedObjectName: string; + namespace: string; + }): Promise; + // (undocumented) getPodLogs({ podName, namespace, clusterName, containerName, + previous, }: { podName: string; namespace: string; clusterName: string; containerName: string; + previous?: boolean; }): Promise<{ text: string; }>; @@ -476,12 +586,62 @@ export const LinkErrorPanel: ({ errorMessage, }: ErrorPanelProps_2) => JSX.Element; +// @public +export const PendingPodContent: ({ + pod, +}: PendingPodContentProps) => JSX.Element; + +// @public +export interface PendingPodContentProps { + // (undocumented) + pod: Pod_2; +} + +// @public +export interface PodAndErrors { + // (undocumented) + clusterName: string; + // (undocumented) + errors: DetectedError[]; + // (undocumented) + pod: Pod_2; +} + // Warning: (ae-forgotten-export) The symbol "PodDrawerProps" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "PodDrawer" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public export const PodDrawer: ({ podAndErrors, open }: PodDrawerProps) => JSX.Element; +// @public +export const PodLogs: React_2.FC; + +// @public +export const PodLogsDialog: ({ + containerScope, +}: PodLogsDialogProps) => JSX.Element; + +// @public +export interface PodLogsDialogProps { + // (undocumented) + containerScope: ContainerScope; +} + +// @public +export interface PodLogsOptions { + // (undocumented) + containerScope: ContainerScope; + // (undocumented) + previous?: boolean; +} + +// @public +export interface PodLogsProps { + // (undocumented) + containerScope: ContainerScope; + // (undocumented) + previous?: boolean; +} + // 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) @@ -494,6 +654,16 @@ export const PodNamesWithMetricsContext: React_2.Context< Map >; +// @public +export interface PodScope { + // (undocumented) + clusterName: string; + // (undocumented) + podName: string; + // (undocumented) + podNamespace: string; +} + // Warning: (ae-forgotten-export) The symbol "PodsTablesProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "PodsTable" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -533,6 +703,13 @@ export const useCustomResources: ( intervalMs?: number, ) => KubernetesObjects; +// @public +export const useEvents: ({ + involvedObjectName, + namespace, + clusterName, +}: EventsOptions) => AsyncState; + // Warning: (ae-missing-release-tag) "useKubernetesObjects" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -540,4 +717,12 @@ export const useKubernetesObjects: ( entity: Entity, intervalMs?: number, ) => KubernetesObjects; + +// @public +export const usePodLogs: ({ + containerScope, + previous, +}: PodLogsOptions) => AsyncState<{ + text: string; +}>; ``` diff --git a/plugins/kubernetes/src/api/KubernetesProxyClient.test.ts b/plugins/kubernetes/src/api/KubernetesProxyClient.test.ts index 6db1e459be..6b0be6de6c 100644 --- a/plugins/kubernetes/src/api/KubernetesProxyClient.test.ts +++ b/plugins/kubernetes/src/api/KubernetesProxyClient.test.ts @@ -14,11 +14,13 @@ * limitations under the License. */ +import { DateTime } from 'luxon'; import { KubernetesProxyClient } from './KubernetesProxyClient'; describe('KubernetesProxyClient', () => { let proxy: KubernetesProxyClient; const callProxyMock = jest.fn(); + const oneHourAgo = DateTime.now().minus({ hours: 1 }).toISO(); beforeEach(() => { jest.resetAllMocks(); @@ -51,4 +53,72 @@ describe('KubernetesProxyClient', () => { path: '/api/v1/namespaces/some-namespace/pods/some-pod/log?container=some-container', }); }); + it('/logs returns log text - crash logs', async () => { + const request = { + podName: 'some-pod', + namespace: 'some-namespace', + clusterName: 'some-cluster', + containerName: 'some-container', + previous: true, + }; + + callProxyMock.mockResolvedValue({ + text: jest.fn().mockResolvedValue('Hello World'), + ok: true, + }); + + const response = await proxy.getPodLogs(request); + await expect(response).toStrictEqual({ text: 'Hello World' }); + expect(callProxyMock).toHaveBeenCalledWith({ + clusterName: 'some-cluster', + init: { + method: 'GET', + }, + path: '/api/v1/namespaces/some-namespace/pods/some-pod/log?container=some-container&previous=', + }); + }); + it('/getEventsByInvolvedObjectName returns events', async () => { + const request = { + clusterName: 'some-cluster', + involvedObjectName: 'some-object', + namespace: 'some-namespace', + }; + const events = [ + { + type: 'Warning', + message: 'uh oh', + reason: 'something happened', + count: 23, + metadata: { + creationTimestamp: oneHourAgo, + }, + }, + { + type: 'Info', + message: 'hello there', + reason: 'something happened', + count: 52, + metadata: { + creationTimestamp: oneHourAgo, + }, + }, + ]; + + callProxyMock.mockResolvedValue({ + json: jest.fn().mockResolvedValue({ + items: events, + }), + ok: true, + }); + + const response = await proxy.getEventsByInvolvedObjectName(request); + await expect(response).toStrictEqual(events); + expect(callProxyMock).toHaveBeenCalledWith({ + clusterName: 'some-cluster', + init: { + method: 'GET', + }, + path: '/api/v1/namespaces/some-namespace/events?fieldSelector=involvedObject.name=some-object', + }); + }); }); diff --git a/plugins/kubernetes/src/api/KubernetesProxyClient.ts b/plugins/kubernetes/src/api/KubernetesProxyClient.ts index 8fd4674ddd..3703d8b180 100644 --- a/plugins/kubernetes/src/api/KubernetesProxyClient.ts +++ b/plugins/kubernetes/src/api/KubernetesProxyClient.ts @@ -14,6 +14,7 @@ * limitations under the License. */ import { KubernetesApi } from './types'; +import { Event } from 'kubernetes-models/v1'; /** * A client for common requests through the proxy endpoint of the kubernetes backend plugin. @@ -41,20 +42,63 @@ export class KubernetesProxyClient { return await response.text(); } + private async handleJson(response: Response): Promise { + if (!response.ok) { + const payload = await response.text(); + let message = `Request failed with ${response.status} ${response.statusText}, ${payload}`; + switch (response.status) { + case 404: + message = `Proxy request failed with ${response.status} ${response.statusText}, ${payload}`; + break; + default: + message = `Request failed with ${response.status} ${response.statusText}, ${payload}`; + } + throw new Error(message); + } + + return await response.json(); + } + + async getEventsByInvolvedObjectName({ + clusterName, + involvedObjectName, + namespace, + }: { + clusterName: string; + involvedObjectName: string; + namespace: string; + }): Promise { + return await this.kubernetesApi + .proxy({ + clusterName, + path: `/api/v1/namespaces/${namespace}/events?fieldSelector=involvedObject.name=${involvedObjectName}`, + init: { + method: 'GET', + }, + }) + .then(response => this.handleJson(response)) + .then(eventList => eventList.items); + } + async getPodLogs({ podName, namespace, clusterName, containerName, + previous, }: { podName: string; namespace: string; clusterName: string; containerName: string; + previous?: boolean; }): Promise<{ text: string }> { const params = new URLSearchParams({ container: containerName, }); + if (previous) { + params.append('previous', ''); + } return await this.kubernetesApi .proxy({ clusterName: clusterName, diff --git a/plugins/kubernetes/src/api/types.ts b/plugins/kubernetes/src/api/types.ts index 5dec189982..6065e47169 100644 --- a/plugins/kubernetes/src/api/types.ts +++ b/plugins/kubernetes/src/api/types.ts @@ -21,6 +21,7 @@ import { CustomObjectsByEntityRequest, } from '@backstage/plugin-kubernetes-common'; import { createApiRef } from '@backstage/core-plugin-api'; +import { Event } from 'kubernetes-models/v1'; export const kubernetesApiRef = createApiRef({ id: 'plugin.kubernetes.service', @@ -60,5 +61,11 @@ export interface KubernetesProxyApi { namespace: string; clusterName: string; containerName: string; + previous?: boolean; }): Promise<{ text: string }>; + getEventsByInvolvedObjectName(request: { + clusterName: string; + involvedObjectName: string; + namespace: string; + }): Promise; } diff --git a/plugins/kubernetes/src/components/Pods/ErrorList/ErrorList.test.tsx b/plugins/kubernetes/src/components/Pods/ErrorList/ErrorList.test.tsx index f28580b509..3ee755becb 100644 --- a/plugins/kubernetes/src/components/Pods/ErrorList/ErrorList.test.tsx +++ b/plugins/kubernetes/src/components/Pods/ErrorList/ErrorList.test.tsx @@ -45,15 +45,13 @@ describe('ErrorList', () => { kind: 'Pod', apiGroup: 'v1', }, - proposedFix: [ - { - type: 'logs', - container: 'some-container', - errorType: 'some error type', - rootCauseExplanation: 'some root cause', - possibleFixes: ['fix1', 'fix2'], - }, - ], + proposedFix: { + type: 'logs', + container: 'some-container', + errorType: 'some error type', + rootCauseExplanation: 'some root cause', + actions: ['fix1', 'fix2'], + }, }, ], }, diff --git a/plugins/kubernetes/src/components/Pods/ErrorList/ErrorList.tsx b/plugins/kubernetes/src/components/Pods/ErrorList/ErrorList.tsx index b7b3418889..4d1d7a3033 100644 --- a/plugins/kubernetes/src/components/Pods/ErrorList/ErrorList.tsx +++ b/plugins/kubernetes/src/components/Pods/ErrorList/ErrorList.tsx @@ -24,8 +24,10 @@ import { makeStyles, Theme, Paper, + Grid, } from '@material-ui/core'; import { PodAndErrors } from '../types'; +import { FixDialog } from '../FixDialog/FixDialog'; const useStyles = makeStyles((_theme: Theme) => createStyles({ @@ -38,10 +40,20 @@ const useStyles = makeStyles((_theme: Theme) => }), ); -interface ErrorListProps { +/** + * Props for ErrorList + * + * @public + */ +export interface ErrorListProps { podAndErrors: PodAndErrors[]; } +/** + * Shows a list of errors found on a Pod + * + * @public + */ export const ErrorList = ({ podAndErrors }: ErrorListProps) => { const classes = useStyles(); return ( @@ -59,10 +71,21 @@ export const ErrorList = ({ podAndErrors }: ErrorListProps) => { > {i > 0 && } - + + + + + + + + ); diff --git a/plugins/kubernetes/src/components/Pods/Events/Events.test.tsx b/plugins/kubernetes/src/components/Pods/Events/Events.test.tsx new file mode 100644 index 0000000000..8323666e66 --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/Events/Events.test.tsx @@ -0,0 +1,96 @@ +/* + * 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 { EventsContent } from './Events'; +import { render } from '@testing-library/react'; +import { Event } from 'kubernetes-models/v1'; +import { DateTime } from 'luxon'; + +describe('EventsContent', () => { + const oneHourAgo = DateTime.now().minus({ hours: 1 }).toISO(); + it('should show info events', () => { + const { getByText } = render( + , + ); + expect(getByText('First event 1 hour ago (count: 52)')).toBeInTheDocument(); + expect(getByText('something happened: hello there')).toBeInTheDocument(); + }); + it('should show warning events', () => { + const { getByText } = render( + , + ); + expect(getByText('First event 1 hour ago (count: 23)')).toBeInTheDocument(); + expect(getByText('something happened: uh oh')).toBeInTheDocument(); + }); + + it('should only show warning events when warningEventsOnly set', () => { + const { getByText, queryByText } = render( + , + ); + expect(queryByText('First event 1 hour ago (count: 52)')).toBeNull(); + expect(queryByText('something happened: hello there')).toBeNull(); + expect(getByText('First event 1 hour ago (count: 23)')).toBeInTheDocument(); + expect(getByText('something happened: uh oh')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/Pods/Events/Events.tsx b/plugins/kubernetes/src/components/Pods/Events/Events.tsx new file mode 100644 index 0000000000..9f888d182c --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/Events/Events.tsx @@ -0,0 +1,154 @@ +/* + * 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 { + Avatar, + Container, + Grid, + List, + ListItem, + ListItemAvatar, + ListItemText, + Tooltip, + Typography, +} from '@material-ui/core'; + +import InfoIcon from '@material-ui/icons/Info'; +import WarningIcon from '@material-ui/icons/Warning'; +import { DateTime } from 'luxon'; + +import { useEvents } from './useEvents'; +import { Skeleton } from '@material-ui/lab'; +import { DismissableBanner } from '@backstage/core-components'; +import { Event } from 'kubernetes-models/v1'; + +/** + * Props for Events + * + * @public + */ +export interface EventsContentProps { + warningEventsOnly?: boolean; + events: Event[]; +} + +const getAvatarByType = (type?: string) => { + return ( + + {type === 'Warning' ? : } + + ); +}; + +/** + * Shows given Kubernetes events + * + * @public + */ +export const EventsContent = ({ + events, + warningEventsOnly, +}: EventsContentProps) => { + if (events.length === 0) { + return No events found; + } + + return ( + + + + {events + .filter(event => { + if (warningEventsOnly) { + return event.type === 'Warning'; + } + return true; + }) + .map(event => { + const timeAgo = event.metadata.creationTimestamp + ? DateTime.fromISO(event.metadata.creationTimestamp).toRelative( + { + locale: 'en', + }, + ) + : 'unknown'; + return ( + + + {getAvatarByType(event.type)} + + + + ); + })} + + + + ); +}; + +/** + * Props for Events + * + * @public + */ +export interface EventsProps { + involvedObjectName: string; + namespace: string; + clusterName: string; + warningEventsOnly?: boolean; +} + +/** + * Retrieves and shows Kubernetes events for the given object + * + * @public + */ +export const Events = ({ + involvedObjectName, + namespace, + clusterName, + warningEventsOnly, +}: EventsProps) => { + const { value, error, loading } = useEvents({ + involvedObjectName, + namespace, + clusterName, + }); + + return ( + <> + {error && ( + + )} + {loading && } + {!loading && value !== undefined && ( + + )} + + ); +}; diff --git a/plugins/kubernetes/src/components/Pods/Events/index.ts b/plugins/kubernetes/src/components/Pods/Events/index.ts new file mode 100644 index 0000000000..da6c4102fa --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/Events/index.ts @@ -0,0 +1,17 @@ +/* + * 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 './Events'; +export * from './useEvents'; diff --git a/plugins/kubernetes/src/components/Pods/Events/useEvents.test.tsx b/plugins/kubernetes/src/components/Pods/Events/useEvents.test.tsx new file mode 100644 index 0000000000..15da221f64 --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/Events/useEvents.test.tsx @@ -0,0 +1,68 @@ +/* + * 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 { useApi } from '@backstage/core-plugin-api'; +import { renderHook } from '@testing-library/react-hooks'; +import { useEvents } from './useEvents'; +import { DateTime } from 'luxon'; + +jest.mock('@backstage/core-plugin-api'); + +jest.mock('@backstage/plugin-kubernetes', () => ({ + kubernetesProxyApiRef: () => jest.fn(), +})); + +const oneHourAgo = DateTime.now().minus({ hours: 1 }).toISO(); + +const response = [ + { + type: 'Info', + message: 'hello there', + reason: 'something happened', + count: 52, + metadata: { + creationTimestamp: oneHourAgo, + }, + }, +] as any; + +describe('Events', () => { + const mockGetEventsByInvolvedObjectName = jest.fn(); + afterEach(() => { + jest.resetAllMocks(); + }); + it('should fetch and show events', async () => { + (useApi as any).mockReturnValue({ + getEventsByInvolvedObjectName: + mockGetEventsByInvolvedObjectName.mockResolvedValue(response), + }); + + const { result, waitForNextUpdate } = renderHook(() => + useEvents({ + involvedObjectName: 'some-objecgt', + namespace: 'some-namespace', + clusterName: 'some-cluster', + }), + ); + + expect(result.current.loading).toEqual(true); + + await waitForNextUpdate(); + + expect(result.current.error).toBeUndefined(); + expect(result.current.loading).toEqual(false); + expect(result.current.value).toStrictEqual(response); + }); +}); diff --git a/plugins/kubernetes/src/components/Pods/Events/useEvents.ts b/plugins/kubernetes/src/components/Pods/Events/useEvents.ts new file mode 100644 index 0000000000..441937507d --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/Events/useEvents.ts @@ -0,0 +1,49 @@ +/* + * 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 { useApi } from '@backstage/core-plugin-api'; +import useAsync from 'react-use/lib/useAsync'; +import { kubernetesProxyApiRef } from '../../../api'; + +/** + * Arguments for useEvents + * + * @public + */ +export interface EventsOptions { + involvedObjectName: string; + namespace: string; + clusterName: string; +} + +/** + * Retrieves the events for the given object + * + * @public + */ +export const useEvents = ({ + involvedObjectName, + namespace, + clusterName, +}: EventsOptions) => { + const kubernetesProxyApi = useApi(kubernetesProxyApiRef); + return useAsync(async () => { + return await kubernetesProxyApi.getEventsByInvolvedObjectName({ + involvedObjectName, + namespace, + clusterName, + }); + }, [involvedObjectName, namespace, clusterName]); +}; diff --git a/plugins/kubernetes/src/components/Pods/FixDialog/FixDialog.test.tsx b/plugins/kubernetes/src/components/Pods/FixDialog/FixDialog.test.tsx new file mode 100644 index 0000000000..e779ab5f0d --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/FixDialog/FixDialog.test.tsx @@ -0,0 +1,159 @@ +/* + * 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 { FixDialog } from './FixDialog'; +import { Pod } from 'kubernetes-models/v1/Pod'; + +jest.mock('../Events', () => ({ + Events: () => { + return ; + }, +})); + +jest.mock('../PodLogs', () => ({ + PodLogs: () => { + return ; + }, +})); + +describe('FixDialog', () => { + it('docs link should render', () => { + const { getByText } = render( + , + ); + expect(getByText('Open docs')).toBeInTheDocument(); + expect(getByText('some error message')).toBeInTheDocument(); + expect(getByText('some-pod - some error type')).toBeInTheDocument(); + expect(getByText('some root cause')).toBeInTheDocument(); + expect(getByText('fix1')).toBeInTheDocument(); + expect(getByText('fix2')).toBeInTheDocument(); + }); + it('events button should render', () => { + const { getByText } = render( + , + ); + expect(getByText('Events:')).toBeInTheDocument(); + expect(getByText('some error message')).toBeInTheDocument(); + expect(getByText('some-pod - some error type')).toBeInTheDocument(); + expect(getByText('some root cause')).toBeInTheDocument(); + expect(getByText('fix1')).toBeInTheDocument(); + expect(getByText('fix2')).toBeInTheDocument(); + }); + it('Logs button should render', () => { + const { getByText } = render( + , + ); + expect(getByText('Crash logs:')).toBeInTheDocument(); + expect(getByText('some error message')).toBeInTheDocument(); + expect(getByText('some-pod - some error type')).toBeInTheDocument(); + expect(getByText('some root cause')).toBeInTheDocument(); + expect(getByText('fix1')).toBeInTheDocument(); + expect(getByText('fix2')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/Pods/FixDialog/FixDialog.tsx b/plugins/kubernetes/src/components/Pods/FixDialog/FixDialog.tsx new file mode 100644 index 0000000000..63ce4af03b --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/FixDialog/FixDialog.tsx @@ -0,0 +1,187 @@ +/* + * 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, { useState } from 'react'; + +import { Button, Grid } from '@material-ui/core'; +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import IconButton from '@material-ui/core/IconButton'; +import { makeStyles, createStyles, Theme } from '@material-ui/core/styles'; +import Typography from '@material-ui/core/Typography'; +import CloseIcon from '@material-ui/icons/Close'; +import HelpIcon from '@material-ui/icons/Help'; +import OpenInNewIcon from '@material-ui/icons/OpenInNew'; + +import { Pod } from 'kubernetes-models/v1/Pod'; +import { DetectedError } from '../../../error-detection'; +import { PodLogs } from '../PodLogs'; +import { Events } from '../Events'; +import { LinkButton } from '@backstage/core-components'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + closeButton: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + color: theme.palette.grey[500], + }, + }), +); + +/** + * Props for FixDialog + * + * @public + */ +export interface FixDialogProps { + open?: boolean; + clusterName: string; + pod: Pod; + error: DetectedError; +} + +/** + * A dialog for fixing detected Kubernetes errors + * + * @public + */ +export const FixDialog: React.FC = ({ + open, + pod, + error, + clusterName, +}: FixDialogProps) => { + const [isOpen, setOpen] = useState(!!open); + const classes = useStyles(); + + const openDialog = () => { + setOpen(true); + }; + + const closeDialog = () => { + setOpen(false); + }; + + const pf = error.proposedFix; + + const dialogContent = () => { + return ( + + + Detected error: + {error.message} + + + Cause explanation: + + {error.proposedFix?.rootCauseExplanation ?? 'unknown'} + + + + Fix: + +
    + {(error.proposedFix?.actions ?? []).map((fix, i) => { + return ( +
  • + {fix} +
  • + ); + })} +
+
+
+ + {pf && pf.type === 'logs' && ( + <> + + Crash logs: + + + + + + )} + {pf && pf.type === 'events' && ( + <> + + Events: + + + + + + )} +
+ ); + }; + + return ( + <> + + + + {pod.metadata?.name} - {error.type} + + + + + {dialogContent()} + + {pf && pf.type === 'docs' && ( + } + target="_blank" + rel="noopener" + > + Open docs + + )} + + + + ); +}; diff --git a/plugins/kubernetes/src/components/Pods/FixDialog/index.ts b/plugins/kubernetes/src/components/Pods/FixDialog/index.ts new file mode 100644 index 0000000000..35601f9bd6 --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/FixDialog/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 './FixDialog'; diff --git a/plugins/kubernetes/src/components/Pods/PodDrawer/ContainerCard.tsx b/plugins/kubernetes/src/components/Pods/PodDrawer/ContainerCard.tsx index eac64ae741..970982d7a3 100644 --- a/plugins/kubernetes/src/components/Pods/PodDrawer/ContainerCard.tsx +++ b/plugins/kubernetes/src/components/Pods/PodDrawer/ContainerCard.tsx @@ -83,12 +83,22 @@ const ContainerDatetime = ({ prefix, dateTime }: ContainerDatetimeProps) => { ); }; +/** + * Props for ContainerCard + * + * @public + */ export interface ContainerCardProps { podScope: PodScope; containerSpec?: IContainer; containerStatus: IContainerStatus; } +/** + * Shows details about a container within a pod + * + * @public + */ export const ContainerCard: React.FC = ({ podScope, containerSpec, @@ -162,7 +172,7 @@ export const ContainerCard: React.FC = ({ { +const PodCondition = ({ condition }: PodConditionProps) => { return ( <> {condition.status === 'False' && ( @@ -64,10 +64,20 @@ export const PodCondition = ({ condition }: PodConditionProps) => { ); }; -interface PendingPodContentProps { +/** + * Props for PendingPodContent + * + * @public + */ +export interface PendingPodContentProps { pod: Pod; } +/** + * Shows details about pod's conditions as it starts + * + * @public + */ export const PendingPodContent = ({ pod }: PendingPodContentProps) => { // TODO add PodHasNetwork when it's out of alpha // https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-conditions diff --git a/plugins/kubernetes/src/components/Pods/PodDrawer/PodDrawer.test.tsx b/plugins/kubernetes/src/components/Pods/PodDrawer/PodDrawer.test.tsx index 64fd38c8d7..2b71976d8a 100644 --- a/plugins/kubernetes/src/components/Pods/PodDrawer/PodDrawer.test.tsx +++ b/plugins/kubernetes/src/components/Pods/PodDrawer/PodDrawer.test.tsx @@ -69,7 +69,7 @@ describe('PodDrawer', () => { container: 'some-container', errorType: 'some error type', rootCauseExplanation: 'some root cause', - possibleFixes: ['fix1', 'fix2'], + actions: ['fix1', 'fix2'], }, ], }, diff --git a/plugins/kubernetes/src/components/Pods/PodDrawer/PodDrawer.tsx b/plugins/kubernetes/src/components/Pods/PodDrawer/PodDrawer.tsx index 5e49410dd4..8e616df14e 100644 --- a/plugins/kubernetes/src/components/Pods/PodDrawer/PodDrawer.tsx +++ b/plugins/kubernetes/src/components/Pods/PodDrawer/PodDrawer.tsx @@ -58,11 +58,22 @@ const useDrawerContentStyles = makeStyles((_theme: Theme) => function getContainerSpecByName(pod: Pod, containerName: string) { return pod.spec?.containers.find(c => c.name === containerName); } + +/** + * Props for PodDrawer + * + * @public + */ interface PodDrawerProps { open?: boolean; podAndErrors: PodAndErrors; } +/** + * A Drawer for Kubernetes Pods + * + * @public + */ export const PodDrawer = ({ podAndErrors, open }: PodDrawerProps) => { const classes = useDrawerContentStyles(); diff --git a/plugins/kubernetes/src/components/Pods/PodDrawer/index.ts b/plugins/kubernetes/src/components/Pods/PodDrawer/index.ts index 17d50ddfcd..92bb254c48 100644 --- a/plugins/kubernetes/src/components/Pods/PodDrawer/index.ts +++ b/plugins/kubernetes/src/components/Pods/PodDrawer/index.ts @@ -13,4 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { PodDrawer } from './PodDrawer'; +export * from './PodDrawer'; +export * from './ContainerCard'; +export * from './PendingPodContent'; diff --git a/plugins/kubernetes/src/components/Pods/PodLogs/PodLogs.tsx b/plugins/kubernetes/src/components/Pods/PodLogs/PodLogs.tsx index 5cff023772..de9341508e 100644 --- a/plugins/kubernetes/src/components/Pods/PodLogs/PodLogs.tsx +++ b/plugins/kubernetes/src/components/Pods/PodLogs/PodLogs.tsx @@ -15,20 +15,39 @@ */ import React from 'react'; -import { DismissableBanner, LogViewer } from '@backstage/core-components'; +import { + DismissableBanner, + EmptyState, + LogViewer, +} from '@backstage/core-components'; import { Paper } from '@material-ui/core'; import { Skeleton } from '@material-ui/lab'; import { ContainerScope } from './types'; import { usePodLogs } from './usePodLogs'; -interface PodLogsProps { - podScope: ContainerScope; +/** + * Props for PodLogs + * + * @public + */ +export interface PodLogsProps { + containerScope: ContainerScope; + previous?: boolean; } -export const PodLogs: React.FC = ({ podScope }: PodLogsProps) => { +/** + * Shows the logs for the given pod + * + * @public + */ +export const PodLogs: React.FC = ({ + containerScope, + previous, +}: PodLogsProps) => { const { value, error, loading } = usePodLogs({ - podScope: podScope, + containerScope, + previous, }); return ( @@ -45,10 +64,20 @@ export const PodLogs: React.FC = ({ podScope }: PodLogsProps) => { )} {loading && } - {!loading && value !== undefined && } + {!loading && + value !== undefined && + (value.text === '' ? ( + + ) : ( + + ))} ); diff --git a/plugins/kubernetes/src/components/Pods/PodLogs/PodLogsDialog.tsx b/plugins/kubernetes/src/components/Pods/PodLogs/PodLogsDialog.tsx index 05d980b26a..48fda2de2a 100644 --- a/plugins/kubernetes/src/components/Pods/PodLogs/PodLogsDialog.tsx +++ b/plugins/kubernetes/src/components/Pods/PodLogs/PodLogsDialog.tsx @@ -43,11 +43,21 @@ const useStyles = makeStyles((theme: Theme) => }), ); -interface PodLogsDialogProps { - podScope: ContainerScope; +/** + * Props for PodLogsDialog + * + * @public + */ +export interface PodLogsDialogProps { + containerScope: ContainerScope; } -export const PodLogsDialog = ({ podScope }: PodLogsDialogProps) => { +/** + * Shows the logs for the given pod in a Dialog + * + * @public + */ +export const PodLogsDialog = ({ containerScope }: PodLogsDialogProps) => { const classes = useStyles(); const [open, setOpen] = useState(false); @@ -62,8 +72,8 @@ export const PodLogsDialog = ({ podScope }: PodLogsDialogProps) => { <> - {podScope.podName} - {podScope.containerName} logs on cluster{' '} - {podScope.clusterName} + {containerScope.podName} - {containerScope.containerName} logs on + cluster {containerScope.clusterName} { - +