feat: Proposed fix dialog (#17576)
* 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> * test: useMatchingErrorTest Signed-off-by: Matthew Clarke <mclarke@spotify.com> * feat: pod fix dialog for errors Signed-off-by: Matthew Clarke <mclarke@spotify.com> * fix: link Signed-off-by: Matthew Clarke <mclarke@spotify.com> * fix: icon tweak 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/plugin-kubernetes-backend': patch
|
||||
'@backstage/plugin-kubernetes': patch
|
||||
---
|
||||
|
||||
Add proposed fix dialog for pod errors
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
/// <reference types="react" />
|
||||
|
||||
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<string, ClusterLinksFormatter>;
|
||||
|
||||
// @public
|
||||
export const ContainerCard: React_2.FC<ContainerCardProps>;
|
||||
|
||||
// @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<FixDialogProps>;
|
||||
|
||||
// @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<Event_2[]>;
|
||||
// (undocumented)
|
||||
getPodLogs(request: {
|
||||
podName: string;
|
||||
namespace: string;
|
||||
clusterName: string;
|
||||
containerName: string;
|
||||
previous?: boolean;
|
||||
}): Promise<{
|
||||
text: string;
|
||||
}>;
|
||||
@@ -436,16 +534,28 @@ export const kubernetesProxyApiRef: ApiRef<KubernetesProxyApi>;
|
||||
export class KubernetesProxyClient {
|
||||
constructor(options: { kubernetesApi: KubernetesApi });
|
||||
// (undocumented)
|
||||
getEventsByInvolvedObjectName({
|
||||
clusterName,
|
||||
involvedObjectName,
|
||||
namespace,
|
||||
}: {
|
||||
clusterName: string;
|
||||
involvedObjectName: string;
|
||||
namespace: string;
|
||||
}): Promise<Event_2[]>;
|
||||
// (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<PodLogsProps>;
|
||||
|
||||
// @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<string, ClientPodStatus>
|
||||
>;
|
||||
|
||||
// @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<Event_2[]>;
|
||||
|
||||
// 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;
|
||||
}>;
|
||||
```
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<any> {
|
||||
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<Event[]> {
|
||||
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,
|
||||
|
||||
@@ -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<KubernetesApi>({
|
||||
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<Event[]>;
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+28
-5
@@ -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 && <Divider key={`error-divider${i}`} />}
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={error.message}
|
||||
secondary={onlyPodWithErrors.pod.metadata?.name}
|
||||
/>
|
||||
<Grid container>
|
||||
<Grid item xs={9}>
|
||||
<ListItemText
|
||||
primary={error.message}
|
||||
secondary={onlyPodWithErrors.pod.metadata?.name}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={3}>
|
||||
<FixDialog
|
||||
pod={onlyPodWithErrors.pod}
|
||||
error={error}
|
||||
clusterName={onlyPodWithErrors.clusterName}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ListItem>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
<EventsContent
|
||||
events={[
|
||||
{
|
||||
type: 'Info',
|
||||
message: 'hello there',
|
||||
reason: 'something happened',
|
||||
count: 52,
|
||||
metadata: {
|
||||
creationTimestamp: oneHourAgo,
|
||||
},
|
||||
} as Event,
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<EventsContent
|
||||
events={[
|
||||
{
|
||||
type: 'Warning',
|
||||
message: 'uh oh',
|
||||
reason: 'something happened',
|
||||
count: 23,
|
||||
metadata: {
|
||||
creationTimestamp: oneHourAgo,
|
||||
},
|
||||
} as Event,
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<EventsContent
|
||||
warningEventsOnly
|
||||
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,
|
||||
},
|
||||
},
|
||||
] as Event[]
|
||||
}
|
||||
/>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<ListItemAvatar>
|
||||
<Avatar>{type === 'Warning' ? <WarningIcon /> : <InfoIcon />}</Avatar>
|
||||
</ListItemAvatar>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows given Kubernetes events
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const EventsContent = ({
|
||||
events,
|
||||
warningEventsOnly,
|
||||
}: EventsContentProps) => {
|
||||
if (events.length === 0) {
|
||||
return <Typography>No events found</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Grid>
|
||||
<List>
|
||||
{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 (
|
||||
<ListItem key={event.metadata.name}>
|
||||
<Tooltip title={`${event.type ?? ''} event`}>
|
||||
{getAvatarByType(event.type)}
|
||||
</Tooltip>
|
||||
<ListItemText
|
||||
primary={`First event ${timeAgo} (count: ${event.count})`}
|
||||
secondary={`${event.reason}: ${event.message}`}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 && (
|
||||
<DismissableBanner
|
||||
{...{
|
||||
message: error.message,
|
||||
variant: 'error',
|
||||
fixed: false,
|
||||
}}
|
||||
id="events"
|
||||
/>
|
||||
)}
|
||||
{loading && <Skeleton variant="rect" width="100%" height="100%" />}
|
||||
{!loading && value !== undefined && (
|
||||
<EventsContent warningEventsOnly={warningEventsOnly} events={value} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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 <React.Fragment data-testid="events" />;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../PodLogs', () => ({
|
||||
PodLogs: () => {
|
||||
return <React.Fragment data-testid="logs" />;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('FixDialog', () => {
|
||||
it('docs link should render', () => {
|
||||
const { getByText } = render(
|
||||
<FixDialog
|
||||
open
|
||||
clusterName="some-cluster"
|
||||
pod={
|
||||
{
|
||||
metadata: {
|
||||
name: 'some-pod',
|
||||
namespace: 'some-namespace',
|
||||
},
|
||||
} as Pod
|
||||
}
|
||||
error={{
|
||||
type: 'some error type',
|
||||
severity: 10,
|
||||
message: 'some error message',
|
||||
occuranceCount: 1,
|
||||
sourceRef: {
|
||||
name: 'some-pod',
|
||||
namespace: 'some-namespace',
|
||||
kind: 'Pod',
|
||||
apiGroup: 'v1',
|
||||
},
|
||||
proposedFix: {
|
||||
type: 'docs',
|
||||
docsLink: 'http://google.com',
|
||||
errorType: 'some error type',
|
||||
rootCauseExplanation: 'some root cause',
|
||||
actions: ['fix1', 'fix2'],
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<FixDialog
|
||||
open
|
||||
clusterName="some-cluster"
|
||||
pod={
|
||||
{
|
||||
metadata: {
|
||||
name: 'some-pod',
|
||||
namespace: 'some-namespace',
|
||||
},
|
||||
} as Pod
|
||||
}
|
||||
error={{
|
||||
type: 'some error type',
|
||||
severity: 10,
|
||||
message: 'some error message',
|
||||
occuranceCount: 1,
|
||||
sourceRef: {
|
||||
name: 'some-pod',
|
||||
namespace: 'some-namespace',
|
||||
kind: 'Pod',
|
||||
apiGroup: 'v1',
|
||||
},
|
||||
proposedFix: {
|
||||
type: 'events',
|
||||
podName: 'some-pod',
|
||||
errorType: 'some error type',
|
||||
rootCauseExplanation: 'some root cause',
|
||||
actions: ['fix1', 'fix2'],
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<FixDialog
|
||||
open
|
||||
clusterName="some-cluster"
|
||||
pod={
|
||||
{
|
||||
metadata: {
|
||||
name: 'some-pod',
|
||||
namespace: 'some-namespace',
|
||||
},
|
||||
} as Pod
|
||||
}
|
||||
error={{
|
||||
type: 'some error type',
|
||||
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',
|
||||
actions: ['fix1', 'fix2'],
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<FixDialogProps> = ({
|
||||
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 (
|
||||
<Grid container>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6">Detected error:</Typography>
|
||||
<Typography>{error.message}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6">Cause explanation:</Typography>
|
||||
<Typography>
|
||||
{error.proposedFix?.rootCauseExplanation ?? 'unknown'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6">Fix:</Typography>
|
||||
<Typography>
|
||||
<ul>
|
||||
{(error.proposedFix?.actions ?? []).map((fix, i) => {
|
||||
return (
|
||||
<li key={`${pod.metadata?.name ?? 'unknown'}-pf-${i}`}>
|
||||
{fix}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
{pf && pf.type === 'logs' && (
|
||||
<>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6">Crash logs:</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={9}>
|
||||
<PodLogs
|
||||
previous
|
||||
containerScope={{
|
||||
podName: pod.metadata?.name ?? 'unknown',
|
||||
podNamespace: pod.metadata?.namespace ?? 'unknown',
|
||||
clusterName: clusterName,
|
||||
containerName: pf.container,
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
{pf && pf.type === 'events' && (
|
||||
<>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6">Events:</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={9}>
|
||||
<Events
|
||||
warningEventsOnly
|
||||
involvedObjectName={pod.metadata?.name ?? ''}
|
||||
namespace={pod.metadata?.namespace ?? ''}
|
||||
clusterName={clusterName}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
aria-label="fix issue"
|
||||
component="label"
|
||||
onClick={openDialog}
|
||||
startIcon={<HelpIcon />}
|
||||
>
|
||||
Help
|
||||
</Button>
|
||||
<Dialog maxWidth="xl" fullWidth open={isOpen} onClose={closeDialog}>
|
||||
<DialogTitle id="dialog-title">
|
||||
{pod.metadata?.name} - {error.type}
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
className={classes.closeButton}
|
||||
onClick={closeDialog}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent>{dialogContent()}</DialogContent>
|
||||
<DialogActions>
|
||||
{pf && pf.type === 'docs' && (
|
||||
<LinkButton
|
||||
to={pf.docsLink}
|
||||
variant="outlined"
|
||||
startIcon={<OpenInNewIcon />}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Open docs
|
||||
</LinkButton>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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<ContainerCardProps> = ({
|
||||
podScope,
|
||||
containerSpec,
|
||||
@@ -162,7 +172,7 @@ export const ContainerCard: React.FC<ContainerCardProps> = ({
|
||||
</CardContent>
|
||||
<CardActions disableSpacing>
|
||||
<PodLogsDialog
|
||||
podScope={{
|
||||
containerScope={{
|
||||
containerName: containerStatus.name,
|
||||
...podScope,
|
||||
}}
|
||||
|
||||
@@ -27,7 +27,7 @@ interface PodConditionProps {
|
||||
condition: IPodCondition;
|
||||
}
|
||||
|
||||
export const PodCondition = ({ condition }: PodConditionProps) => {
|
||||
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
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('PodDrawer', () => {
|
||||
container: 'some-container',
|
||||
errorType: 'some error type',
|
||||
rootCauseExplanation: 'some root cause',
|
||||
possibleFixes: ['fix1', 'fix2'],
|
||||
actions: ['fix1', 'fix2'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
+3
-1
@@ -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';
|
||||
|
||||
+36
-7
@@ -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<PodLogsProps> = ({ podScope }: PodLogsProps) => {
|
||||
/**
|
||||
* Shows the logs for the given pod
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const PodLogs: React.FC<PodLogsProps> = ({
|
||||
containerScope,
|
||||
previous,
|
||||
}: PodLogsProps) => {
|
||||
const { value, error, loading } = usePodLogs({
|
||||
podScope: podScope,
|
||||
containerScope,
|
||||
previous,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -45,10 +64,20 @@ export const PodLogs: React.FC<PodLogsProps> = ({ podScope }: PodLogsProps) => {
|
||||
)}
|
||||
<Paper
|
||||
elevation={1}
|
||||
style={{ height: '100%', width: '100%', minHeight: '30rem' }}
|
||||
style={{ height: '100%', width: '100%', minHeight: '15rem' }}
|
||||
>
|
||||
{loading && <Skeleton variant="rect" width="100%" height="100%" />}
|
||||
{!loading && value !== undefined && <LogViewer text={value.text} />}
|
||||
{!loading &&
|
||||
value !== undefined &&
|
||||
(value.text === '' ? (
|
||||
<EmptyState
|
||||
missing="data"
|
||||
title="No logs emitted"
|
||||
description="No logs were emitted by the container"
|
||||
/>
|
||||
) : (
|
||||
<LogViewer text={value.text} />
|
||||
))}
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
<>
|
||||
<Dialog maxWidth="xl" fullWidth open={open} onClose={closeDialog}>
|
||||
<DialogTitle id="dialog-title">
|
||||
{podScope.podName} - {podScope.containerName} logs on cluster{' '}
|
||||
{podScope.clusterName}
|
||||
{containerScope.podName} - {containerScope.containerName} logs on
|
||||
cluster {containerScope.clusterName}
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
className={classes.closeButton}
|
||||
@@ -73,7 +83,7 @@ export const PodLogsDialog = ({ podScope }: PodLogsDialogProps) => {
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<PodLogs podScope={podScope} />
|
||||
<PodLogs containerScope={containerScope} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button
|
||||
|
||||
@@ -14,12 +14,22 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Contains the details needed to make a log request to Kubernetes, except the container name
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface PodScope {
|
||||
podName: string;
|
||||
podNamespace: string;
|
||||
clusterName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains the details needed to make a log request to Kubernetes
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface ContainerScope extends PodScope {
|
||||
containerName: string;
|
||||
}
|
||||
|
||||
+20
-8
@@ -19,18 +19,30 @@ import { ContainerScope } from './types';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { kubernetesProxyApiRef } from '../../../api';
|
||||
|
||||
interface PodLogsOptions {
|
||||
podScope: ContainerScope;
|
||||
/**
|
||||
* Arguments for usePodLogs
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface PodLogsOptions {
|
||||
containerScope: ContainerScope;
|
||||
previous?: boolean;
|
||||
}
|
||||
|
||||
export const usePodLogs = ({ podScope }: PodLogsOptions) => {
|
||||
/**
|
||||
* Retrieves the logs for the given pod
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const usePodLogs = ({ containerScope, previous }: PodLogsOptions) => {
|
||||
const kubernetesProxyApi = useApi(kubernetesProxyApiRef);
|
||||
return useAsync(async () => {
|
||||
return await kubernetesProxyApi.getPodLogs({
|
||||
podName: podScope.podName,
|
||||
namespace: podScope.podNamespace,
|
||||
containerName: podScope.containerName,
|
||||
clusterName: podScope.clusterName,
|
||||
podName: containerScope.podName,
|
||||
namespace: containerScope.podNamespace,
|
||||
containerName: containerScope.containerName,
|
||||
clusterName: containerScope.clusterName,
|
||||
previous,
|
||||
});
|
||||
}, [JSON.stringify(podScope)]);
|
||||
}, [JSON.stringify(containerScope)]);
|
||||
};
|
||||
|
||||
+6
-1
@@ -13,5 +13,10 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { PodDrawer } from './PodDrawer';
|
||||
export * from './PodDrawer';
|
||||
export * from './PodLogs';
|
||||
export * from './FixDialog';
|
||||
export * from './Events';
|
||||
export * from './ErrorList';
|
||||
export { PodsTable } from './PodsTable';
|
||||
export * from './types';
|
||||
|
||||
+5
@@ -16,6 +16,11 @@
|
||||
import { Pod } from 'kubernetes-models/v1';
|
||||
import { DetectedError } from '../../error-detection';
|
||||
|
||||
/**
|
||||
* Wraps a pod with the associated detected errors and cluster name
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface PodAndErrors {
|
||||
clusterName: string;
|
||||
pod: Pod;
|
||||
|
||||
@@ -28,7 +28,6 @@ const deploymentErrorMappers: ErrorMapper<Deployment>[] = [
|
||||
type: 'condition-message-present',
|
||||
message: c.message ?? '',
|
||||
severity: 6,
|
||||
proposedFix: [], // TODO next PR
|
||||
sourceRef: {
|
||||
name: deployment.metadata?.name ?? 'unknown hpa',
|
||||
namespace: deployment.metadata?.namespace ?? 'unknown namespace',
|
||||
|
||||
@@ -156,8 +156,15 @@ describe('detectErrors', () => {
|
||||
message:
|
||||
'back-off 5m0s restarting failed container=other-side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)',
|
||||
severity: 4,
|
||||
proposedFix: {
|
||||
container: 'other-side-car',
|
||||
errorType: 'CrashLoopBackOff',
|
||||
actions: ['Check the crash logs for stacktraces'],
|
||||
rootCauseExplanation:
|
||||
'The container other-side-car has crashed many times, it will be exponentially restarted until it stops crashing',
|
||||
type: 'logs',
|
||||
},
|
||||
occuranceCount: 1,
|
||||
proposedFix: [],
|
||||
type: 'container-waiting',
|
||||
});
|
||||
|
||||
@@ -168,11 +175,18 @@ describe('detectErrors', () => {
|
||||
name: 'dice-roller-canary-7d64cd756c-55rfq',
|
||||
namespace: 'default',
|
||||
},
|
||||
proposedFix: {
|
||||
container: 'other-side-car',
|
||||
errorType: 'CrashLoopBackOff',
|
||||
actions: ['Check the crash logs for stacktraces'],
|
||||
rootCauseExplanation:
|
||||
'The container other-side-car has crashed many times, it will be exponentially restarted until it stops crashing',
|
||||
type: 'logs',
|
||||
},
|
||||
message:
|
||||
'back-off 5m0s restarting failed container=side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)',
|
||||
severity: 4,
|
||||
occuranceCount: 1,
|
||||
proposedFix: [],
|
||||
type: 'container-waiting',
|
||||
});
|
||||
|
||||
@@ -186,7 +200,14 @@ describe('detectErrors', () => {
|
||||
message: 'container=other-side-car restarted 123 times',
|
||||
severity: 4,
|
||||
occuranceCount: 123,
|
||||
proposedFix: [],
|
||||
proposedFix: {
|
||||
container: 'other-side-car',
|
||||
errorType: 'Error',
|
||||
actions: ['Check the crash logs for stacktraces'],
|
||||
rootCauseExplanation:
|
||||
'This container has exited with a non-zero exit code (1)',
|
||||
type: 'logs',
|
||||
},
|
||||
type: 'containers-restarting',
|
||||
});
|
||||
|
||||
@@ -197,10 +218,17 @@ describe('detectErrors', () => {
|
||||
name: 'dice-roller-canary-7d64cd756c-55rfq',
|
||||
namespace: 'default',
|
||||
},
|
||||
proposedFix: {
|
||||
container: 'other-side-car',
|
||||
errorType: 'Error',
|
||||
actions: ['Check the crash logs for stacktraces'],
|
||||
rootCauseExplanation:
|
||||
'This container has exited with a non-zero exit code (1)',
|
||||
type: 'logs',
|
||||
},
|
||||
message: 'container=side-car restarted 38 times',
|
||||
severity: 4,
|
||||
occuranceCount: 38,
|
||||
proposedFix: [],
|
||||
type: 'containers-restarting',
|
||||
});
|
||||
});
|
||||
@@ -219,7 +247,18 @@ describe('detectErrors', () => {
|
||||
expect(err1).toStrictEqual({
|
||||
message: 'configmap "some-cm" not found',
|
||||
occuranceCount: 1,
|
||||
proposedFix: [],
|
||||
proposedFix: {
|
||||
docsLink:
|
||||
'https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/',
|
||||
errorType: 'CreateContainerConfigError',
|
||||
actions: [
|
||||
'Ensure ConfigMaps references in the Deployment manifest are correct and the keys exist',
|
||||
'Ensure Secrets references in the Deployment manifest are correct and the keys exist',
|
||||
],
|
||||
rootCauseExplanation:
|
||||
'There is missing or mismatching configuration required to start the container',
|
||||
type: 'docs',
|
||||
},
|
||||
severity: 4,
|
||||
sourceRef: {
|
||||
apiGroup: 'v1',
|
||||
@@ -262,7 +301,6 @@ describe('detectErrors', () => {
|
||||
message: 'Deployment does not have minimum availability.',
|
||||
severity: 6,
|
||||
occuranceCount: 1,
|
||||
proposedFix: [],
|
||||
type: 'condition-message-present',
|
||||
});
|
||||
});
|
||||
@@ -299,7 +337,6 @@ describe('detectErrors', () => {
|
||||
'Current number of replicas (10) is equal to the configured max number of replicas (10)',
|
||||
severity: 8,
|
||||
occuranceCount: 1,
|
||||
proposedFix: [],
|
||||
type: 'hpa-max-current-replicas',
|
||||
});
|
||||
});
|
||||
@@ -433,7 +470,17 @@ describe('detectErrors', () => {
|
||||
message:
|
||||
'The container some-container failed to start properly, but is not crashing',
|
||||
occuranceCount: 1,
|
||||
proposedFix: [],
|
||||
proposedFix: {
|
||||
errorType: 'ReadinessProbeFailed',
|
||||
podName: '',
|
||||
actions: [
|
||||
'Ensure that the container starts correctly locally',
|
||||
"Check the container's logs looking for error during startup",
|
||||
],
|
||||
rootCauseExplanation:
|
||||
'The container some-container failed to start properly, but is not crashing',
|
||||
type: 'events',
|
||||
},
|
||||
severity: 4,
|
||||
sourceRef: {
|
||||
apiGroup: 'v1',
|
||||
|
||||
@@ -31,7 +31,6 @@ const hpaErrorMappers: ErrorMapper<HorizontalPodAutoscaler>[] = [
|
||||
hpa.spec?.maxReplicas ?? -1
|
||||
})`,
|
||||
severity: 8,
|
||||
proposedFix: [], // TODO next PR
|
||||
sourceRef: {
|
||||
name: hpa.metadata?.name ?? 'unknown hpa',
|
||||
namespace: hpa.metadata?.namespace ?? 'unknown namespace',
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { Pod, IContainerStatus, IContainer } from 'kubernetes-models/v1';
|
||||
import { DetectedError, ErrorMapper } from './types';
|
||||
import { DetectedError, ErrorMapper, ProposedFix } from './types';
|
||||
import { detectErrorsInObjects } from './common';
|
||||
import lodash from 'lodash';
|
||||
import { DateTime } from 'luxon';
|
||||
@@ -70,6 +70,129 @@ const podToContainerSpecsAndStatuses = (pod: Pod): ContainerSpecAndStatus[] => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const readinessProbeProposedFixes = (pod: Pod): ProposedFix | undefined => {
|
||||
const firstUnreadyContainerStatus = pod.status?.containerStatuses?.find(
|
||||
cs => {
|
||||
return cs.ready === false;
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
errorType: 'ReadinessProbeFailed',
|
||||
rootCauseExplanation: `The container ${firstUnreadyContainerStatus?.name} failed to start properly, but is not crashing`,
|
||||
actions: [
|
||||
'Ensure that the container starts correctly locally',
|
||||
"Check the container's logs looking for error during startup",
|
||||
],
|
||||
type: 'events',
|
||||
podName: pod.metadata?.name ?? '',
|
||||
};
|
||||
};
|
||||
|
||||
const restartingPodProposedFixes = (pod: Pod): ProposedFix | undefined => {
|
||||
const lastTerminatedCs = (pod.status?.containerStatuses ?? []).find(
|
||||
cs => cs.lastState?.terminated !== undefined,
|
||||
);
|
||||
|
||||
const lastTerminated = lastTerminatedCs?.lastState?.terminated;
|
||||
|
||||
if (!lastTerminated) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (lastTerminated?.reason) {
|
||||
case 'Unknown':
|
||||
return {
|
||||
// TODO check this one, it's more likely a cluster issue
|
||||
errorType: 'Unknown',
|
||||
rootCauseExplanation: `This container has exited with a non-zero exit code (${lastTerminated.exitCode})`,
|
||||
actions: ['Check the crash logs for stacktraces'],
|
||||
container: lastTerminatedCs.name,
|
||||
type: 'logs',
|
||||
};
|
||||
case 'Error':
|
||||
return {
|
||||
errorType: 'Error',
|
||||
rootCauseExplanation: `This container has exited with a non-zero exit code (${lastTerminated.exitCode})`,
|
||||
actions: ['Check the crash logs for stacktraces'],
|
||||
container: lastTerminatedCs.name,
|
||||
type: 'logs',
|
||||
};
|
||||
case 'OOMKilled':
|
||||
return {
|
||||
errorType: 'OOMKilled',
|
||||
rootCauseExplanation: `The container "${lastTerminatedCs.name}" has crashed because it has tried to use more memory that it has been allocated`,
|
||||
actions: [
|
||||
`Increase the amount of memory assigned to the container`,
|
||||
'Ensure the application is memory bounded and is not trying to consume too much memory',
|
||||
],
|
||||
docsLink:
|
||||
'https://kubernetes.io/docs/tasks/configure-pod-container/assign-memory-resource/#exceed-a-container-s-memory-limit',
|
||||
type: 'docs',
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const waitingProposedFix = (pod: Pod): ProposedFix | undefined => {
|
||||
const waitingCs = (pod.status?.containerStatuses ?? []).find(
|
||||
cs => cs.state?.waiting !== undefined,
|
||||
);
|
||||
|
||||
const waiting = (pod.status?.containerStatuses ?? [])
|
||||
.map(cs => cs.state?.waiting)
|
||||
.find(w => w?.reason !== undefined);
|
||||
|
||||
switch (waiting?.reason) {
|
||||
case 'InvalidImageName':
|
||||
return {
|
||||
errorType: 'InvalidImageName',
|
||||
rootCauseExplanation: 'The image in the pod is invalid',
|
||||
actions: ['Ensure the image name is correct and valid image name'],
|
||||
type: 'docs',
|
||||
docsLink:
|
||||
'https://docs.docker.com/engine/reference/commandline/tag/#extended-description',
|
||||
};
|
||||
case 'ImagePullBackOff':
|
||||
return {
|
||||
errorType: 'ImagePullBackOff',
|
||||
rootCauseExplanation:
|
||||
'The image either could not be found or Kubernetes does not have permission to pull it',
|
||||
actions: [
|
||||
'Ensure the image name is correct',
|
||||
'Ensure Kubernetes has permission to pull this image',
|
||||
],
|
||||
type: 'docs',
|
||||
docsLink:
|
||||
'https://kubernetes.io/docs/concepts/containers/images/#imagepullbackoff',
|
||||
};
|
||||
case 'CrashLoopBackOff':
|
||||
return {
|
||||
errorType: 'CrashLoopBackOff',
|
||||
rootCauseExplanation: `The container ${waitingCs?.name} has crashed many times, it will be exponentially restarted until it stops crashing`,
|
||||
actions: ['Check the crash logs for stacktraces'],
|
||||
type: 'logs',
|
||||
container: waitingCs?.name ?? 'unknown',
|
||||
};
|
||||
case 'CreateContainerConfigError':
|
||||
return {
|
||||
errorType: 'CreateContainerConfigError',
|
||||
rootCauseExplanation:
|
||||
'There is missing or mismatching configuration required to start the container',
|
||||
actions: [
|
||||
'Ensure ConfigMaps references in the Deployment manifest are correct and the keys exist',
|
||||
'Ensure Secrets references in the Deployment manifest are correct and the keys exist',
|
||||
],
|
||||
type: 'docs',
|
||||
docsLink:
|
||||
'https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/',
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const podErrorMappers: ErrorMapper<Pod>[] = [
|
||||
{
|
||||
detectErrors: pod => {
|
||||
@@ -79,7 +202,7 @@ const podErrorMappers: ErrorMapper<Pod>[] = [
|
||||
type: 'readiness-probe-taking-too-long',
|
||||
message: `The container ${cs.container.name} failed to start properly, but is not crashing`,
|
||||
severity: 4,
|
||||
proposedFix: [], // TODO next PR
|
||||
proposedFix: readinessProbeProposedFixes(pod),
|
||||
sourceRef: {
|
||||
name: pod.metadata?.name ?? 'unknown pod',
|
||||
namespace: pod.metadata?.namespace ?? 'unknown namespace',
|
||||
@@ -98,7 +221,7 @@ const podErrorMappers: ErrorMapper<Pod>[] = [
|
||||
type: 'container-waiting',
|
||||
message: cs.state?.waiting?.message ?? 'container waiting',
|
||||
severity: 4,
|
||||
proposedFix: [], // TODO next PR
|
||||
proposedFix: waitingProposedFix(pod),
|
||||
sourceRef: {
|
||||
name: pod.metadata?.name ?? 'unknown pod',
|
||||
namespace: pod.metadata?.namespace ?? 'unknown namespace',
|
||||
@@ -117,7 +240,7 @@ const podErrorMappers: ErrorMapper<Pod>[] = [
|
||||
type: 'containers-restarting',
|
||||
message: `container=${cs.name} restarted ${cs.restartCount} times`,
|
||||
severity: 4,
|
||||
proposedFix: [], // TODO next PR
|
||||
proposedFix: restartingPodProposedFixes(pod),
|
||||
sourceRef: {
|
||||
name: pod.metadata?.name ?? 'unknown pod',
|
||||
namespace: pod.metadata?.namespace ?? 'unknown namespace',
|
||||
|
||||
@@ -44,17 +44,17 @@ export interface DetectedError {
|
||||
type: string;
|
||||
severity: ErrorSeverity;
|
||||
message: string;
|
||||
proposedFix: ProposedFix[];
|
||||
proposedFix?: ProposedFix;
|
||||
sourceRef: ResourceRef;
|
||||
occuranceCount: number;
|
||||
}
|
||||
|
||||
type ProposedFix = LogSolution | DocsSolution | EventsSolution;
|
||||
export type ProposedFix = LogSolution | DocsSolution | EventsSolution;
|
||||
|
||||
interface ProposedFixBase {
|
||||
errorType: string;
|
||||
rootCauseExplanation: string;
|
||||
possibleFixes: string[];
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
export interface LogSolution extends ProposedFixBase {
|
||||
@@ -69,7 +69,6 @@ export interface DocsSolution extends ProposedFixBase {
|
||||
|
||||
export interface EventsSolution extends ProposedFixBase {
|
||||
type: 'events';
|
||||
docsLink: string;
|
||||
podName: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,15 +26,13 @@ const genericErrorWithRef = (resourceRef: ResourceRef): DetectedError => {
|
||||
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'],
|
||||
},
|
||||
],
|
||||
proposedFix: {
|
||||
type: 'logs',
|
||||
container: 'some-container',
|
||||
errorType: 'some error type',
|
||||
rootCauseExplanation: 'some root cause',
|
||||
actions: ['fix1', 'fix2'],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user