refactor: Refactors the Kubernetes plugin in line with ADR11 (#19446)

* refactor: k8s plugins

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

* chore: move more files

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

* chore: fix

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

* fix: tsc

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

* chore: changeset

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

* chore: api report

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

* fix: catalog

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

* fix: types

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

* fix: update lock

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

---------

Signed-off-by: Matthew Clarke <mclarke@spotify.com>
This commit is contained in:
Matthew Clarke
2023-09-26 18:40:10 -04:00
committed by GitHub
parent ec13fcc4a7
commit 2d8151061c
215 changed files with 17249 additions and 992 deletions
@@ -18,13 +18,19 @@ import React from 'react';
import { screen } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import { KubernetesContent } from './KubernetesContent';
import { useKubernetesObjects } from '../hooks';
import { useKubernetesObjects } from '@backstage/plugin-kubernetes-react';
import * as oneDeployment from './__fixtures__/1-deployments.json';
import * as twoDeployments from './__fixtures__/2-deployments.json';
jest.mock('../hooks');
import * as oneDeployment from '../__fixtures__/1-deployments.json';
import * as twoDeployments from '../__fixtures__/2-deployments.json';
jest.mock('@backstage/plugin-kubernetes-react', () => ({
...jest.requireActual('@backstage/plugin-kubernetes-react'),
useKubernetesObjects: jest.fn(),
}));
describe('KubernetesContent', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('render empty response', async () => {
(useKubernetesObjects as any).mockReturnValue({
kubernetesObjects: {
@@ -17,14 +17,23 @@
import React from 'react';
import { Grid, Typography } from '@material-ui/core';
import { Entity } from '@backstage/catalog-model';
import { ErrorPanel } from './ErrorPanel';
import { ErrorReporting } from './ErrorReporting';
import { DetectedError, detectErrors } from '../error-detection';
import { Cluster } from './Cluster';
import EmptyStateImage from '../assets/emptystate.svg';
import { useKubernetesObjects } from '../hooks';
import { Content, Page, Progress } from '@backstage/core-components';
import { DetectedErrorsContext } from '../hooks/useMatchingErrors';
import {
ErrorPanel,
ErrorReporting,
Cluster,
useKubernetesObjects,
DetectedErrorsContext,
} from '@backstage/plugin-kubernetes-react';
import {
DetectedError,
detectErrors,
} from '@backstage/plugin-kubernetes-common';
import {
Content,
EmptyState,
Page,
Progress,
} from '@backstage/core-components';
type KubernetesContentProps = {
entity: Entity;
@@ -98,17 +107,11 @@ export const KubernetesContent = ({
alignItems="center"
spacing={2}
>
<Grid item xs={4}>
<Typography variant="h5">
No resources on any known clusters for{' '}
{entity.metadata.name}
</Typography>
</Grid>
<Grid item xs={4}>
<img
src={EmptyStateImage}
alt="EmptyState"
data-testid="emptyStateImg"
<Grid item xs={8}>
<EmptyState
missing="data"
title="No Kubernetes resources"
description={`No resources on any known clusters for ${entity.metadata.name}`}
/>
</Grid>
</Grid>
+1 -1
View File
@@ -18,7 +18,7 @@ import React from 'react';
import { Entity } from '@backstage/catalog-model';
import { useEntity } from '@backstage/plugin-catalog-react';
import { Route, Routes } from 'react-router-dom';
import { KubernetesContent } from './components/KubernetesContent';
import { KubernetesContent } from './KubernetesContent';
import { Button } from '@material-ui/core';
import { MissingAnnotationEmptyState } from '@backstage/core-components';
@@ -1,563 +0,0 @@
/*
* 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 { KubernetesAuthProvidersApi } from '../kubernetes-auth-provider';
import { KubernetesBackendClient } from './KubernetesBackendClient';
import { rest } from 'msw';
import { UrlPatternDiscovery } from '@backstage/core-app-api';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/test-utils';
import {
CustomObjectsByEntityRequest,
KubernetesRequestBody,
ObjectsByEntityResponse,
WorkloadsByEntityRequest,
} from '@backstage/plugin-kubernetes-common';
import { NotFoundError } from '@backstage/errors';
describe('KubernetesBackendClient', () => {
let backendClient: KubernetesBackendClient;
const kubernetesAuthProvidersApi: jest.Mocked<KubernetesAuthProvidersApi> = {
decorateRequestBodyForAuth: jest.fn(),
getCredentials: jest.fn(),
};
let mockResponse: ObjectsByEntityResponse;
const worker = setupServer();
setupRequestMockHandlers(worker);
const identityApi = {
getCredentials: jest.fn(),
getProfileInfo: jest.fn(),
getBackstageIdentity: jest.fn(),
signOut: jest.fn(),
};
beforeEach(() => {
jest.resetAllMocks();
backendClient = new KubernetesBackendClient({
discoveryApi: UrlPatternDiscovery.compile(
'http://localhost:1234/api/{{ pluginId }}',
),
identityApi,
kubernetesAuthProvidersApi,
});
mockResponse = {
items: [
{
cluster: {
name: 'cluster-a',
},
resources: [{ type: 'pods', resources: [] }],
podMetrics: [
{
pod: {},
cpu: { currentUsage: 8, requestTotal: 2, limitTotal: 1 },
memory: { currentUsage: 8, requestTotal: 2, limitTotal: 1 },
containers: [
{
container: 'test',
cpuUsage: { currentUsage: 8, requestTotal: 2, limitTotal: 1 },
memoryUsage: {
currentUsage: 8,
requestTotal: 2,
limitTotal: 1,
},
},
],
},
],
errors: [],
},
],
};
});
it('hits the /clusters API', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) =>
res(ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] })),
),
);
const clusters = await backendClient.getClusters();
expect(clusters).toStrictEqual([
{ name: 'cluster-a', authProvider: 'aws' },
]);
});
it('/clusters API throws a 404 Error', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) =>
res(ctx.status(404)),
),
);
await expect(backendClient.getClusters()).rejects.toThrow(
'Could not find the Kubernetes Backend (HTTP 404). Make sure the plugin has been fully installed.',
);
});
it('/clusters API throws a 500 Error', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) =>
res(ctx.status(500)),
),
);
await expect(backendClient.getClusters()).rejects.toThrow(
'Request failed with 500 Internal Server Error, ',
);
});
it('hits the /resources/custom/query API', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.post(
'http://localhost:1234/api/kubernetes/resources/custom/query',
(_, res, ctx) => res(ctx.json(mockResponse)),
),
);
const request: CustomObjectsByEntityRequest = {
auth: {},
customResources: [
{
group: 'test-group',
apiVersion: 'v1',
plural: 'none',
},
],
entity: {
apiVersion: 'v1',
kind: 'pod',
metadata: {
name: 'test-name',
},
},
};
const customObject: ObjectsByEntityResponse =
await backendClient.getCustomObjectsByEntity(request);
expect(customObject).toStrictEqual(mockResponse);
});
it('/resources/custom/query API throws a 404 error', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.post(
'http://localhost:1234/api/kubernetes/resources/custom/query',
(_, res, ctx) => res(ctx.status(404)),
),
);
const request: CustomObjectsByEntityRequest = {
auth: {},
customResources: [
{
group: 'test-group',
apiVersion: 'v1',
plural: 'none',
},
],
entity: {
apiVersion: 'v1',
kind: 'pod',
metadata: {
name: 'test-name',
},
},
};
const response = backendClient.getCustomObjectsByEntity(request);
await expect(response).rejects.toThrow(
'Could not find the Kubernetes Backend (HTTP 404). Make sure the plugin has been fully installed.',
);
});
it('/resources/custom/query API throws a 500 error', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.post(
'http://localhost:1234/api/kubernetes/resources/custom/query',
(_, res, ctx) => res(ctx.status(500)),
),
);
const request: CustomObjectsByEntityRequest = {
auth: {},
customResources: [
{
group: 'test-group',
apiVersion: 'v1',
plural: 'none',
},
],
entity: {
apiVersion: 'v1',
kind: 'pod',
metadata: {
name: 'test-name',
},
},
};
const response = backendClient.getCustomObjectsByEntity(request);
await expect(response).rejects.toThrow(
'Request failed with 500 Internal Server Error, ',
);
});
it('hits the /services/{entityName} API', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.post(
'http://localhost:1234/api/kubernetes/services/test-name',
(_, res, ctx) => res(ctx.json(mockResponse)),
),
);
const request: KubernetesRequestBody = {
entity: {
apiVersion: 'v1',
kind: 'pod',
metadata: {
name: 'test-name',
},
},
};
const entityObject: ObjectsByEntityResponse =
await backendClient.getObjectsByEntity(request);
expect(entityObject).toStrictEqual(mockResponse);
});
it('services/{entityName} API throws a 404 error', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.post(
'http://localhost:1234/api/kubernetes/services/test-name',
(_, res, ctx) => res(ctx.status(404)),
),
);
const request: KubernetesRequestBody = {
entity: {
apiVersion: 'v1',
kind: 'pod',
metadata: {
name: 'test-name',
},
},
};
const response = backendClient.getObjectsByEntity(request);
await expect(response).rejects.toThrow(
'Could not find the Kubernetes Backend (HTTP 404). Make sure the plugin has been fully installed.',
);
});
it('services/{entityName} API throws a 500 error', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.post(
'http://localhost:1234/api/kubernetes/services/test-name',
(_, res, ctx) => res(ctx.status(500)),
),
);
const request: KubernetesRequestBody = {
entity: {
apiVersion: 'v1',
kind: 'pod',
metadata: {
name: 'test-name',
},
},
};
const response = backendClient.getObjectsByEntity(request);
await expect(response).rejects.toThrow(
'Request failed with 500 Internal Server Error, ',
);
});
it('hits the /resources/workloads/query API', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.post(
'http://localhost:1234/api/kubernetes/resources/workloads/query',
(_, res, ctx) => res(ctx.json(mockResponse)),
),
);
const request: WorkloadsByEntityRequest = {
auth: {},
entity: {
apiVersion: 'v1',
kind: 'pod',
metadata: {
name: 'test-name',
},
},
};
const response: ObjectsByEntityResponse =
await backendClient.getWorkloadsByEntity(request);
expect(response).toStrictEqual(mockResponse);
});
it('/resources/workloads/query API throws a 404 error', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.post(
'http://localhost:1234/api/kubernetes/resources/workloads/query',
(_, res, ctx) => res(ctx.status(404)),
),
);
const request: WorkloadsByEntityRequest = {
auth: {},
entity: {
apiVersion: 'v1',
kind: 'pod',
metadata: {
name: 'test-name',
},
},
};
const response = backendClient.getWorkloadsByEntity(request);
await expect(response).rejects.toThrow(
'Could not find the Kubernetes Backend (HTTP 404). Make sure the plugin has been fully installed.',
);
});
it('/resources/workloads/query API throws a 500 error', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.post(
'http://localhost:1234/api/kubernetes/resources/workloads/query',
(_, res, ctx) => res(ctx.status(500)),
),
);
const request: WorkloadsByEntityRequest = {
auth: {},
entity: {
apiVersion: 'v1',
kind: 'pod',
metadata: {
name: 'test-name',
},
},
};
const response = backendClient.getWorkloadsByEntity(request);
await expect(response).rejects.toThrow(
'Request failed with 500 Internal Server Error, ',
);
});
describe('proxy', () => {
beforeEach(() => {
worker.use(
rest.get(
'http://localhost:1234/api/kubernetes/clusters',
(_, res, ctx) =>
res(
ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] }),
),
),
);
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
});
it('hits the /proxy API', async () => {
kubernetesAuthProvidersApi.getCredentials.mockResolvedValue({
token: 'k8-token',
});
const nsResponse = {
kind: 'Namespace',
apiVersion: 'v1',
metadata: {
name: 'new-ns',
},
};
worker.use(
rest.get(
'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces',
(req, res, ctx) =>
res(
req.headers.get('Backstage-Kubernetes-Authorization') ===
'Bearer k8-token'
? ctx.json(nsResponse)
: ctx.status(403),
),
),
);
const request = {
clusterName: 'cluster-a',
path: '/api/v1/namespaces',
};
const response = await backendClient.proxy(request);
await expect(response.json()).resolves.toStrictEqual(nsResponse);
});
it('hits /proxy api when signed in as a guest', async () => {
// when a user is signed in as a guest the result of the getCredentials() method resolves to the {} value.
identityApi.getCredentials.mockResolvedValue({});
kubernetesAuthProvidersApi.getCredentials.mockResolvedValue({
token: 'k8-token',
});
const nsResponse = {
kind: 'Namespace',
apiVersion: 'v1',
metadata: {
name: 'new-ns',
},
};
worker.use(
rest.get(
'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces',
(req, res, ctx) =>
res(
req.headers.get('Backstage-Kubernetes-Authorization') ===
'Bearer k8-token'
? ctx.json(nsResponse)
: ctx.status(403),
),
),
);
const request = {
clusterName: 'cluster-a',
path: '/api/v1/namespaces',
};
const response = await backendClient.proxy(request);
await expect(response.json()).resolves.toStrictEqual(nsResponse);
});
it('/proxy API throws a 404 error', async () => {
kubernetesAuthProvidersApi.getCredentials.mockResolvedValue({
token: 'k8-token',
});
worker.use(
rest.get(
'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces',
(_, res, ctx) => res(ctx.status(404)),
),
);
const request = {
clusterName: 'cluster-a',
path: '/api/v1/namespaces',
};
const response = await backendClient.proxy(request);
expect(response.status).toEqual(404);
});
it('throws a ERROR_NOT_FOUND if the cluster in the request is not found', async () => {
const request = {
clusterName: 'cluster-b',
path: '/api/v1/namespaces',
};
await expect(backendClient.proxy(request)).rejects.toThrow(NotFoundError);
});
it('responds with an 403 error when invalid k8 token is provided', async () => {
kubernetesAuthProvidersApi.getCredentials.mockResolvedValue({
token: 'wrong-token',
});
const nsResponse = {
kind: 'Namespace',
apiVersion: 'v1',
metadata: {
name: 'new-ns',
},
};
worker.use(
rest.get(
'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces',
(req, res, ctx) =>
res(
req.headers.get('Backstage-Kubernetes-Authorization') ===
'Bearer k8-token'
? ctx.json(nsResponse)
: ctx.status(403),
),
),
);
const request = {
clusterName: 'cluster-a',
path: '/api/v1/namespaces',
};
const response = await backendClient.proxy(request);
expect(response.status).toEqual(403);
});
it('skips authorization header when auth provider returns no creds', async () => {
const nsResponse = {
kind: 'Namespace',
apiVersion: 'v1',
metadata: {
name: 'new-ns',
},
};
worker.use(
rest.get(
'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces/new-ns',
(req, res, ctx) =>
res(
req.headers.get('Backstage-Kubernetes-Authorization')
? ctx.status(403)
: ctx.json(nsResponse),
),
),
);
kubernetesAuthProvidersApi.getCredentials.mockResolvedValue({});
const response = await backendClient.proxy({
clusterName: 'cluster-a',
path: '/api/v1/namespaces/new-ns',
});
await expect(response.json()).resolves.toStrictEqual(nsResponse);
});
});
});
@@ -1,161 +0,0 @@
/*
* Copyright 2020 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 { KubernetesApi } from './types';
import {
KubernetesRequestBody,
ObjectsByEntityResponse,
WorkloadsByEntityRequest,
CustomObjectsByEntityRequest,
} from '@backstage/plugin-kubernetes-common';
import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { KubernetesAuthProvidersApi } from '../kubernetes-auth-provider';
import { NotFoundError } from '@backstage/errors';
export class KubernetesBackendClient implements KubernetesApi {
private readonly discoveryApi: DiscoveryApi;
private readonly identityApi: IdentityApi;
private readonly kubernetesAuthProvidersApi: KubernetesAuthProvidersApi;
constructor(options: {
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
kubernetesAuthProvidersApi: KubernetesAuthProvidersApi;
}) {
this.discoveryApi = options.discoveryApi;
this.identityApi = options.identityApi;
this.kubernetesAuthProvidersApi = options.kubernetesAuthProvidersApi;
}
private async handleResponse(response: Response): Promise<any> {
if (!response.ok) {
const payload = await response.text();
let message;
switch (response.status) {
case 404:
message =
'Could not find the Kubernetes Backend (HTTP 404). Make sure the plugin has been fully installed.';
break;
default:
message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
}
throw new Error(message);
}
return await response.json();
}
private async postRequired(path: string, requestBody: any): Promise<any> {
const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}${path}`;
const { token: idToken } = await this.identityApi.getCredentials();
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(idToken && { Authorization: `Bearer ${idToken}` }),
},
body: JSON.stringify(requestBody),
});
return this.handleResponse(response);
}
private async getCluster(
clusterName: string,
): Promise<{ name: string; authProvider: string }> {
const cluster = await this.getClusters().then(clusters =>
clusters.find(c => c.name === clusterName),
);
if (!cluster) {
throw new NotFoundError(`Cluster ${clusterName} not found`);
}
return cluster;
}
private async getCredentials(
authProvider: string,
): Promise<{ token?: string }> {
return await this.kubernetesAuthProvidersApi.getCredentials(authProvider);
}
async getObjectsByEntity(
requestBody: KubernetesRequestBody,
): Promise<ObjectsByEntityResponse> {
return await this.postRequired(
`/services/${requestBody.entity.metadata.name}`,
requestBody,
);
}
async getWorkloadsByEntity(
request: WorkloadsByEntityRequest,
): Promise<ObjectsByEntityResponse> {
return await this.postRequired('/resources/workloads/query', {
auth: request.auth,
entityRef: stringifyEntityRef(request.entity),
});
}
async getCustomObjectsByEntity(
request: CustomObjectsByEntityRequest,
): Promise<ObjectsByEntityResponse> {
return await this.postRequired(`/resources/custom/query`, {
entityRef: stringifyEntityRef(request.entity),
auth: request.auth,
customResources: request.customResources,
});
}
async getClusters(): Promise<{ name: string; authProvider: string }[]> {
const { token: idToken } = await this.identityApi.getCredentials();
const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/clusters`;
const response = await fetch(url, {
method: 'GET',
headers: {
...(idToken && { Authorization: `Bearer ${idToken}` }),
},
});
return (await this.handleResponse(response)).items;
}
async proxy(options: {
clusterName: string;
path: string;
init?: RequestInit;
}): Promise<Response> {
const { authProvider } = await this.getCluster(options.clusterName);
const { token: k8sToken } = await this.getCredentials(authProvider);
const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/proxy${
options.path
}`;
const identityResponse = await this.identityApi.getCredentials();
const headers = {
...options.init?.headers,
[`Backstage-Kubernetes-Cluster`]: options.clusterName,
...(k8sToken && {
[`Backstage-Kubernetes-Authorization`]: `Bearer ${k8sToken}`,
}),
...(identityResponse.token && {
Authorization: `Bearer ${identityResponse.token}`,
}),
};
return await fetch(url, { ...options.init, headers });
}
}
@@ -1,124 +0,0 @@
/*
* 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 { 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();
proxy = new KubernetesProxyClient({
kubernetesApi: {
proxy: callProxyMock,
} as any,
});
});
it('/logs returns log text', async () => {
const request = {
podName: 'some-pod',
namespace: 'some-namespace',
clusterName: 'some-cluster',
containerName: 'some-container',
};
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',
});
});
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',
});
});
});
@@ -1,113 +0,0 @@
/*
* 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 { KubernetesApi } from './types';
import { Event } from 'kubernetes-models/v1';
/**
* A client for common requests through the proxy endpoint of the kubernetes backend plugin.
*
* @public
*/
export class KubernetesProxyClient {
private readonly kubernetesApi: KubernetesApi;
constructor(options: { kubernetesApi: KubernetesApi }) {
this.kubernetesApi = options.kubernetesApi;
}
private async handleText(response: Response): Promise<string> {
if (!response.ok) {
const payload = await response.text();
let message;
switch (response.status) {
default:
message = `Proxy request failed with ${response.status} ${response.statusText}, ${payload}`;
}
throw new Error(message);
}
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,
path: `/api/v1/namespaces/${namespace}/pods/${podName}/log?${params.toString()}`,
init: {
method: 'GET',
},
})
.then(response => this.handleText(response))
.then(text => ({ text }));
}
}
-20
View File
@@ -1,20 +0,0 @@
/*
* Copyright 2021 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 { kubernetesApiRef, kubernetesProxyApiRef } from './types';
export type { KubernetesApi, KubernetesProxyApi } from './types';
export { KubernetesBackendClient } from './KubernetesBackendClient';
export { KubernetesProxyClient } from './KubernetesProxyClient';
-71
View File
@@ -1,71 +0,0 @@
/*
* Copyright 2020 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 {
KubernetesRequestBody,
ObjectsByEntityResponse,
WorkloadsByEntityRequest,
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',
});
export const kubernetesProxyApiRef = createApiRef<KubernetesProxyApi>({
id: 'plugin.kubernetes.proxy-service',
});
export interface KubernetesApi {
getObjectsByEntity(
requestBody: KubernetesRequestBody,
): Promise<ObjectsByEntityResponse>;
getClusters(): Promise<
{
name: string;
authProvider: string;
oidcTokenProvider?: string | undefined;
}[]
>;
getWorkloadsByEntity(
request: WorkloadsByEntityRequest,
): Promise<ObjectsByEntityResponse>;
getCustomObjectsByEntity(
request: CustomObjectsByEntityRequest,
): Promise<ObjectsByEntityResponse>;
proxy(options: {
clusterName: string;
path: string;
init?: RequestInit;
}): Promise<Response>;
}
export interface KubernetesProxyApi {
getPodLogs(request: {
podName: string;
namespace: string;
clusterName: string;
containerName: string;
previous?: boolean;
}): Promise<{ text: string }>;
getEventsByInvolvedObjectName(request: {
clusterName: string;
involvedObjectName: string;
namespace: string;
}): Promise<Event[]>;
}
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="282" height="173" fill="none" viewBox="0 0 282 173"><path fill="#000" fill-opacity=".05" fill-rule="evenodd" d="M16.4571 45.1637C11.0514 46.1711 7.48574 51.3699 8.49306 56.7756C9.50039 62.1814 14.6992 65.747 20.105 64.7397L27.5528 63.3518C25.4791 65.5835 24.4525 68.7347 25.0535 71.9596C26.0608 77.3653 31.2596 80.931 36.6654 79.9236L89.691 70.0427C89.7016 70.1067 89.7129 70.1708 89.7249 70.2349C90.3258 73.4598 92.4185 76.0298 95.1569 77.3647L91.9031 77.971C86.4974 78.9784 82.9318 84.1772 83.9391 89.583C84.9464 94.9887 90.1452 98.5543 95.551 97.547L250.098 68.7482C255.504 67.7409 259.069 62.5421 258.062 57.1363C257.461 53.9114 255.368 51.3414 252.63 50.0065L257.835 49.0366C263.241 48.0292 266.807 42.8304 265.799 37.4247C264.792 32.0189 259.593 28.4533 254.187 29.4606L161.492 46.7338C161.481 46.6697 161.47 46.6056 161.458 46.5415C160.857 43.3166 158.764 40.7466 156.026 39.4117L165.025 37.7347C170.431 36.7274 173.997 31.5286 172.989 26.1228C171.982 20.7171 166.783 17.1514 161.378 18.1588L16.4571 45.1637ZM24.3031 122.54C23.2958 117.134 26.8614 111.936 32.2672 110.928L190.856 81.3762C196.262 80.3688 201.461 83.9345 202.468 89.3402C203.476 94.746 199.91 99.9448 194.504 100.952L189.963 101.798C190.493 102.057 190.999 102.362 191.474 102.708L246.43 92.4677C251.835 91.4604 257.034 95.026 258.041 100.432C258.642 103.657 257.616 106.808 255.542 109.04L256.649 108.833C262.055 107.826 267.253 111.392 268.261 116.797C269.268 122.203 265.702 127.402 260.297 128.409L95.5591 159.107C90.1534 160.114 84.9545 156.549 83.9472 151.143C82.9399 145.737 86.5055 140.538 91.9113 139.531L103.94 137.29C103.41 137.031 102.904 136.726 102.429 136.38L29.1002 150.044C23.6944 151.051 18.4956 147.486 17.4882 142.08C16.4809 136.674 20.0465 131.475 25.4523 130.468L29.7352 129.67C26.9967 128.335 24.904 125.765 24.3031 122.54Z" clip-rule="evenodd"/><circle cx="188" cy="55" r="6" fill="#69DDC7"/><circle cx="91" cy="92" r="6" fill="#69DDC7"/><path fill="#69DDC7" d="M121 114L95.5 88L86.5 96L121 130L192.5 59L183.5 51L121 114Z"/></svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

@@ -1,59 +0,0 @@
/*
* Copyright 2021 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 { screen } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import { Cluster } from './Cluster';
jest.mock('../../hooks');
import * as oneDeployment from '../../__fixtures__/1-deployments.json';
describe('Cluster', () => {
it('render 1 cluster', async () => {
await renderInTestApp(
<Cluster
{...({
clusterObjects: {
cluster: {
name: 'cluster-1',
},
resources: [
{
type: 'deployments',
resources: oneDeployment.deployments,
},
{
type: 'replicasets',
resources: oneDeployment.replicaSets,
},
{
type: 'pods',
resources: oneDeployment.pods,
},
],
podMetrics: [],
errors: [],
},
podsWithErrors: new Set<string>(),
} as any)}
/>,
);
expect(screen.getByText('cluster-1')).toBeInTheDocument();
expect(screen.getByText('10 pods')).toBeInTheDocument();
});
});
@@ -1,170 +0,0 @@
/*
* Copyright 2020 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 {
Accordion,
AccordionDetails,
AccordionSummary,
Grid,
Typography,
} from '@material-ui/core';
import {
ClientPodStatus,
ClusterObjects,
} from '@backstage/plugin-kubernetes-common';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { DeploymentsAccordions } from '../DeploymentsAccordions';
import { StatefulSetsAccordions } from '../StatefulSetsAccordions';
import { groupResponses } from '../../utils/response';
import { IngressesAccordions } from '../IngressesAccordions';
import { ServicesAccordions } from '../ServicesAccordions';
import { CronJobsAccordions } from '../CronJobsAccordions';
import { CustomResources } from '../CustomResources';
import {
ClusterContext,
GroupedResponsesContext,
PodNamesWithErrorsContext,
} from '../../hooks';
import { StatusError, StatusOK } from '@backstage/core-components';
import { PodMetricsContext } from '../../hooks/usePodMetrics';
type ClusterSummaryProps = {
clusterName: string;
totalNumberOfPods: number;
numberOfPodsWithErrors: number;
children?: React.ReactNode;
};
const ClusterSummary = ({
clusterName,
totalNumberOfPods,
numberOfPodsWithErrors,
}: ClusterSummaryProps) => {
return (
<Grid
container
direction="row"
justifyContent="space-between"
alignItems="flex-start"
spacing={0}
>
<Grid
xs={6}
item
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
spacing={0}
>
<Grid item xs>
<Typography variant="body1">{clusterName}</Typography>
<Typography color="textSecondary" variant="subtitle1">
Cluster
</Typography>
</Grid>
</Grid>
<Grid
item
container
xs={3}
direction="column"
justifyContent="flex-start"
alignItems="flex-end"
spacing={0}
>
<Grid item>
<StatusOK>{totalNumberOfPods} pods</StatusOK>
</Grid>
<Grid item>
{numberOfPodsWithErrors > 0 ? (
<StatusError>{numberOfPodsWithErrors} pods with errors</StatusError>
) : (
<StatusOK>No pods with errors</StatusOK>
)}
</Grid>
</Grid>
</Grid>
);
};
type ClusterProps = {
clusterObjects: ClusterObjects;
podsWithErrors: Set<string>;
children?: React.ReactNode;
};
export const Cluster = ({ clusterObjects, podsWithErrors }: ClusterProps) => {
const groupedResponses = groupResponses(clusterObjects.resources);
const podMetricsMap = new Map<string, ClientPodStatus[]>();
podMetricsMap.set(clusterObjects.cluster.name, clusterObjects.podMetrics);
return (
<ClusterContext.Provider value={clusterObjects.cluster}>
<GroupedResponsesContext.Provider value={groupedResponses}>
<PodMetricsContext.Provider value={podMetricsMap}>
<PodNamesWithErrorsContext.Provider value={podsWithErrors}>
<Accordion TransitionProps={{ unmountOnExit: true }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<ClusterSummary
clusterName={clusterObjects.cluster.name}
totalNumberOfPods={groupedResponses.pods.length}
numberOfPodsWithErrors={podsWithErrors.size}
/>
</AccordionSummary>
<AccordionDetails>
<Grid container direction="column">
{groupedResponses.customResources.length > 0 ? (
<Grid item>
<CustomResources />
</Grid>
) : undefined}
{groupedResponses.deployments.length > 0 ? (
<Grid item>
<DeploymentsAccordions />
</Grid>
) : undefined}
{groupedResponses.statefulsets.length > 0 ? (
<Grid item>
<StatefulSetsAccordions />
</Grid>
) : undefined}
{groupedResponses.ingresses.length > 0 ? (
<Grid item>
<IngressesAccordions />
</Grid>
) : undefined}
{groupedResponses.services.length > 0 ? (
<Grid item>
<ServicesAccordions />
</Grid>
) : undefined}
{groupedResponses.cronJobs.length > 0 ? (
<Grid item>
<CronJobsAccordions />
</Grid>
) : undefined}
</Grid>
</AccordionDetails>
</Accordion>
</PodNamesWithErrorsContext.Provider>
</PodMetricsContext.Provider>
</GroupedResponsesContext.Provider>
</ClusterContext.Provider>
);
};
@@ -1,16 +0,0 @@
/*
* Copyright 2020 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 { Cluster } from './Cluster';
@@ -1,47 +0,0 @@
/*
* Copyright 2021 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 { screen } from '@testing-library/react';
import { CronJobsAccordions } from './CronJobsAccordions';
import * as oneCronJobsFixture from '../../__fixtures__/1-cronjobs.json';
import * as twoCronJobsFixture from '../../__fixtures__/2-cronjobs.json';
import { renderInTestApp } from '@backstage/test-utils';
import { kubernetesProviders } from '../../hooks/test-utils';
describe('CronJobsAccordions', () => {
it('should render 1 active cronjobs', async () => {
const wrapper = kubernetesProviders(oneCronJobsFixture, new Set<string>());
await renderInTestApp(wrapper(<CronJobsAccordions />));
expect(screen.getByText('dice-roller-cronjob')).toBeInTheDocument();
expect(screen.getByText('CronJob')).toBeInTheDocument();
expect(screen.getByText('namespace: default')).toBeInTheDocument();
expect(screen.getByText('Active')).toBeInTheDocument();
});
it('should render 1 suspended cronjobs', async () => {
const wrapper = kubernetesProviders(twoCronJobsFixture, new Set<string>());
await renderInTestApp(wrapper(<CronJobsAccordions />));
expect(screen.getByText('dice-roller-cronjob')).toBeInTheDocument();
expect(screen.getByText('CronJob')).toBeInTheDocument();
expect(screen.getByText('namespace: default')).toBeInTheDocument();
expect(screen.getByText('Suspended')).toBeInTheDocument();
});
});
@@ -1,126 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useContext } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Grid,
Typography,
} from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { V1CronJob, V1Job } from '@kubernetes/client-node';
import { JobsAccordions } from '../JobsAccordions';
import { CronJobDrawer } from './CronJobsDrawer';
import { getOwnedResources } from '../../utils/owner';
import { GroupedResponsesContext } from '../../hooks';
import { StatusError, StatusOK } from '@backstage/core-components';
import { humanizeCron } from '../../utils/crons';
type CronJobsAccordionsProps = {
children?: React.ReactNode;
};
type CronJobAccordionProps = {
cronJob: V1CronJob;
ownedJobs: V1Job[];
children?: React.ReactNode;
};
type CronJobSummaryProps = {
cronJob: V1CronJob;
children?: React.ReactNode;
};
const CronJobSummary = ({ cronJob }: CronJobSummaryProps) => {
return (
<Grid
container
direction="row"
justifyContent="space-between"
alignItems="center"
spacing={0}
>
<Grid xs={6} item>
<CronJobDrawer cronJob={cronJob} />
</Grid>
<Grid
item
container
xs={6}
direction="column"
justifyContent="flex-start"
alignItems="flex-end"
spacing={0}
>
<Grid item>
{cronJob.spec?.suspend ? (
<StatusError>Suspended</StatusError>
) : (
<StatusOK>Active</StatusOK>
)}
</Grid>
<Grid item>
<Typography variant="body1">
Schedule:{' '}
{cronJob.spec?.schedule
? `${cronJob.spec.schedule} (${humanizeCron(
cronJob.spec.schedule,
)})`
: 'N/A'}
</Typography>
</Grid>
</Grid>
</Grid>
);
};
const CronJobAccordion = ({ cronJob, ownedJobs }: CronJobAccordionProps) => {
return (
<Accordion TransitionProps={{ unmountOnExit: true }} variant="outlined">
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<CronJobSummary cronJob={cronJob} />
</AccordionSummary>
<AccordionDetails>
<JobsAccordions jobs={ownedJobs.reverse()} />
</AccordionDetails>
</Accordion>
);
};
export const CronJobsAccordions = ({}: CronJobsAccordionsProps) => {
const groupedResponses = useContext(GroupedResponsesContext);
return (
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
>
{groupedResponses.cronJobs.map((cronJob, i) => (
<Grid container item key={i} xs>
<Grid item xs>
<CronJobAccordion
ownedJobs={getOwnedResources(cronJob, groupedResponses.jobs)}
cronJob={cronJob}
/>
</Grid>
</Grid>
))}
</Grid>
);
};
@@ -1,38 +0,0 @@
/*
* Copyright 2021 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 * as oneCronJobsFixture from '../../__fixtures__/1-cronjobs.json';
import { renderInTestApp } from '@backstage/test-utils';
import { CronJobDrawer } from './CronJobsDrawer';
describe('CronJobDrawer', () => {
it('should render cronJob drawer', async () => {
const { getByText, getAllByText } = await renderInTestApp(
<CronJobDrawer
cronJob={(oneCronJobsFixture as any).cronJobs[0]}
expanded
/>,
);
expect(getAllByText('dice-roller-cronjob')).toHaveLength(2);
expect(getAllByText('CronJob')).toHaveLength(2);
expect(getByText('YAML')).toBeInTheDocument();
expect(getByText('Schedule')).toBeInTheDocument();
expect(getByText('30 5 * * *')).toBeInTheDocument();
expect(getByText('Starting Deadline Seconds')).toBeInTheDocument();
expect(getByText('Last Schedule Time')).toBeInTheDocument();
});
});
@@ -1,67 +0,0 @@
/*
* Copyright 2021 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 { V1CronJob } from '@kubernetes/client-node';
import { KubernetesStructuredMetadataTableDrawer } from '../KubernetesDrawer';
import { Typography, Grid, Chip } from '@material-ui/core';
export const CronJobDrawer = ({
cronJob,
expanded,
}: {
cronJob: V1CronJob;
expanded?: boolean;
}) => {
const namespace = cronJob.metadata?.namespace;
return (
<KubernetesStructuredMetadataTableDrawer
object={cronJob}
expanded={expanded}
kind="CronJob"
renderObject={(cronJobObj: V1CronJob) => ({
schedule: cronJobObj.spec?.schedule ?? '???',
startingDeadlineSeconds:
cronJobObj.spec?.startingDeadlineSeconds ?? '???',
concurrencyPolicy: cronJobObj.spec?.concurrencyPolicy ?? '???',
lastScheduleTime: cronJobObj.status?.lastScheduleTime ?? '???',
})}
>
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
spacing={0}
>
<Grid item>
<Typography variant="body1">
{cronJob.metadata?.name ?? 'unknown object'}
</Typography>
</Grid>
<Grid item>
<Typography color="textSecondary" variant="subtitle1">
CronJob
</Typography>
</Grid>
{namespace && (
<Grid item>
<Chip size="small" label={`namespace: ${namespace}`} />
</Grid>
)}
</Grid>
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -1,16 +0,0 @@
/*
* Copyright 2021 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 { CronJobsAccordions } from './CronJobsAccordions';
@@ -1,102 +0,0 @@
/*
* Copyright 2021 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 { screen } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import { kubernetesProviders } from '../../../hooks/test-utils';
import * as rollout from './__fixtures__/rollout.json';
import * as pausedRollout from './__fixtures__/paused-rollout.json';
import * as abortedRollout from './__fixtures__/aborted-rollout.json';
import * as groupedResources from './__fixtures__/grouped-resources.json';
import { RolloutAccordions } from './Rollout';
import { DateTime, Duration } from 'luxon';
describe('Rollout', () => {
it('should render RolloutAccordion', async () => {
const wrapper = kubernetesProviders(groupedResources, new Set([]));
await renderInTestApp(
wrapper(<RolloutAccordions rollouts={[rollout] as any} />),
);
expect(screen.getByText('dice-roller')).toBeInTheDocument();
expect(screen.getByText('Rollout')).toBeInTheDocument();
expect(screen.getByText('2 pods')).toBeInTheDocument();
expect(screen.getByText('No pods with errors')).toBeInTheDocument();
expect(screen.queryByText('Paused')).toBeNull();
});
it('should render RolloutAccordion with error', async () => {
const wrapper = kubernetesProviders(
groupedResources,
new Set(['dice-roller-6c8646bfd-2m5hv']),
);
await renderInTestApp(
wrapper(<RolloutAccordions rollouts={[rollout] as any} />),
);
expect(screen.getByText('dice-roller')).toBeInTheDocument();
expect(screen.getByText('Rollout')).toBeInTheDocument();
expect(screen.getByText('2 pods')).toBeInTheDocument();
expect(screen.getByText('1 pod with errors')).toBeInTheDocument();
expect(screen.queryByText('Paused')).toBeNull();
});
it('should render Paused Rollout with pause text', async () => {
const wrapper = kubernetesProviders(groupedResources, new Set([]));
(pausedRollout.status.pauseConditions[0].startTime as any) =
DateTime.local()
// millis * secs * mins = 45 mins
.minus(Duration.fromMillis(1000 * 60 * 45));
await renderInTestApp(
wrapper(<RolloutAccordions rollouts={[pausedRollout] as any} />),
);
expect(screen.getByText('dice-roller')).toBeInTheDocument();
expect(screen.getByText('Rollout')).toBeInTheDocument();
expect(screen.getByText('2 pods')).toBeInTheDocument();
expect(screen.getByText('No pods with errors')).toBeInTheDocument();
expect(screen.getByText('Paused (45 minutes ago)')).toBeInTheDocument();
});
it('should render aborted Rollout with aborted text', async () => {
const wrapper = kubernetesProviders(groupedResources, new Set([]));
await renderInTestApp(
wrapper(
<RolloutAccordions
defaultExpanded
rollouts={[abortedRollout] as any}
/>,
),
);
expect(screen.getByText('dice-roller')).toBeInTheDocument();
expect(screen.getByText('Rollout')).toBeInTheDocument();
expect(screen.getByText('2 pods')).toBeInTheDocument();
expect(screen.getByText('No pods with errors')).toBeInTheDocument();
expect(screen.queryByText('Paused')).toBeNull();
expect(screen.getByText('Rollout status')).toBeInTheDocument();
expect(screen.getAllByText('Aborted')).toHaveLength(2);
expect(
screen.getByText('some metric related failure message'),
).toBeInTheDocument();
});
});
@@ -1,289 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useContext } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Grid,
Typography,
} from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { V1Pod, V1HorizontalPodAutoscaler } from '@kubernetes/client-node';
import { PodsTable } from '../../Pods';
import { HorizontalPodAutoscalerDrawer } from '../../HorizontalPodAutoscalers';
import { RolloutDrawer } from './RolloutDrawer';
import PauseIcon from '@material-ui/icons/Pause';
import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline';
import { DateTime } from 'luxon';
import { StepsProgress } from './StepsProgress';
import {
PodNamesWithErrorsContext,
GroupedResponsesContext,
} from '../../../hooks';
import {
getMatchingHpa,
getOwnedPodsThroughReplicaSets,
} from '../../../utils/owner';
import { StatusError, StatusOK } from '@backstage/core-components';
import { READY_COLUMNS, RESOURCE_COLUMNS } from '../../Pods/PodsTable';
type RolloutAccordionsProps = {
rollouts: any[];
defaultExpanded?: boolean;
children?: React.ReactNode;
};
type RolloutAccordionProps = {
rollout: any;
ownedPods: V1Pod[];
defaultExpanded?: boolean;
matchingHpa?: V1HorizontalPodAutoscaler;
children?: React.ReactNode;
};
type RolloutSummaryProps = {
rollout: any;
numberOfCurrentPods: number;
numberOfPodsWithErrors: number;
hpa?: V1HorizontalPodAutoscaler;
children?: React.ReactNode;
};
const AbortedTitle = (
<div
style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<ErrorOutlineIcon />
<Typography variant="subtitle1">Aborted</Typography>
</div>
);
const findAbortedMessage = (rollout: any): string | undefined =>
rollout.status?.conditions?.find(
(c: any) =>
c.type === 'Progressing' &&
c.status === 'False' &&
c.reason === 'RolloutAborted',
)?.message;
const RolloutSummary = ({
rollout,
numberOfCurrentPods,
numberOfPodsWithErrors,
hpa,
}: RolloutSummaryProps) => {
const pauseTime: string | undefined = rollout.status?.pauseConditions?.find(
(p: any) => p.reason === 'CanaryPauseStep',
)?.startTime;
const abortedMessage = findAbortedMessage(rollout);
return (
<Grid
container
direction="row"
justifyContent="space-between"
alignItems="center"
spacing={0}
>
<Grid xs={6} item>
<RolloutDrawer rollout={rollout} />
</Grid>
{hpa && (
<Grid item xs={3}>
<HorizontalPodAutoscalerDrawer hpa={hpa}>
<Grid
item
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
spacing={0}
>
<Grid item>
<Typography variant="subtitle2">
min replicas {hpa.spec?.minReplicas ?? '?'} / max replicas{' '}
{hpa.spec?.maxReplicas ?? '?'}
</Typography>
</Grid>
<Grid item>
<Typography variant="subtitle2">
current CPU usage:{' '}
{hpa.status?.currentCPUUtilizationPercentage ?? '?'}%
</Typography>
</Grid>
<Grid item>
<Typography variant="subtitle2">
target CPU usage:{' '}
{hpa.spec?.targetCPUUtilizationPercentage ?? '?'}%
</Typography>
</Grid>
</Grid>
</HorizontalPodAutoscalerDrawer>
</Grid>
)}
<Grid
item
container
xs={3}
direction="column"
justifyContent="flex-start"
alignItems="flex-end"
spacing={0}
>
<Grid item>
<StatusOK>{numberOfCurrentPods} pods</StatusOK>
</Grid>
<Grid item>
{numberOfPodsWithErrors > 0 ? (
<StatusError>
{numberOfPodsWithErrors} pod
{numberOfPodsWithErrors > 1 ? 's' : ''} with errors
</StatusError>
) : (
<StatusOK>No pods with errors</StatusOK>
)}
</Grid>
</Grid>
{pauseTime && (
<Grid item xs={3}>
<div
style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<PauseIcon />
<Typography variant="subtitle1">
Paused ({DateTime.fromISO(pauseTime).toRelative({ locale: 'en' })}
)
</Typography>
</div>
</Grid>
)}
{abortedMessage && (
<Grid item xs={3}>
{AbortedTitle}
</Grid>
)}
</Grid>
);
};
const RolloutAccordion = ({
rollout,
ownedPods,
matchingHpa,
defaultExpanded,
}: RolloutAccordionProps) => {
const podNamesWithErrors = useContext(PodNamesWithErrorsContext);
const podsWithErrors = ownedPods.filter(p =>
podNamesWithErrors.has(p.metadata?.name ?? ''),
);
const currentStepIndex = rollout.status?.currentStepIndex ?? 0;
const abortedMessage = findAbortedMessage(rollout);
return (
<Accordion
defaultExpanded={defaultExpanded}
TransitionProps={{ unmountOnExit: true }}
variant="outlined"
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<RolloutSummary
rollout={rollout}
numberOfCurrentPods={ownedPods.length}
numberOfPodsWithErrors={podsWithErrors.length}
hpa={matchingHpa}
/>
</AccordionSummary>
<AccordionDetails>
<div style={{ width: '100%' }}>
<div>
<Typography variant="h6">Rollout status</Typography>
</div>
<div style={{ margin: '1rem' }}>
{abortedMessage && (
<>
{AbortedTitle}
<Typography variant="subtitle2">{abortedMessage}</Typography>
</>
)}
<StepsProgress
aborted={abortedMessage !== undefined}
steps={rollout.spec?.strategy?.canary?.steps ?? []}
currentStepIndex={currentStepIndex}
/>
</div>
<div>
<PodsTable
pods={ownedPods}
extraColumns={[READY_COLUMNS, RESOURCE_COLUMNS]}
/>
</div>
</div>
</AccordionDetails>
</Accordion>
);
};
export const RolloutAccordions = ({
rollouts,
defaultExpanded = false,
}: RolloutAccordionsProps) => {
const groupedResponses = useContext(GroupedResponsesContext);
return (
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
>
{rollouts.map((rollout, i) => (
<Grid container item key={i} xs>
<Grid item xs>
<RolloutAccordion
defaultExpanded={defaultExpanded}
matchingHpa={getMatchingHpa(
{
name: rollout.metadata?.name,
namespace: rollout.metadata?.namespace,
kind: 'rollout',
},
groupedResponses.horizontalPodAutoscalers,
)}
ownedPods={getOwnedPodsThroughReplicaSets(
rollout,
groupedResponses.replicaSets,
groupedResponses.pods,
)}
rollout={rollout}
/>
</Grid>
</Grid>
))}
</Grid>
);
};
@@ -1,55 +0,0 @@
/*
* Copyright 2020 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 { KubernetesStructuredMetadataTableDrawer } from '../../KubernetesDrawer';
import { Typography, Grid } from '@material-ui/core';
export const RolloutDrawer = ({
rollout,
expanded,
}: {
rollout: any;
expanded?: boolean;
}) => {
return (
<KubernetesStructuredMetadataTableDrawer
object={rollout}
expanded={expanded}
kind="Rollout"
renderObject={() => ({})}
>
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
spacing={0}
>
<Grid item>
<Typography variant="body1">
{rollout.metadata?.name ?? 'unknown object'}
</Typography>
</Grid>
<Grid item>
<Typography color="textSecondary" variant="subtitle1">
Rollout
</Typography>
</Grid>
</Grid>
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -1,127 +0,0 @@
/*
* Copyright 2020 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 { screen } from '@testing-library/react';
import pauseSteps from './__fixtures__/pause-steps';
import setWeightSteps from './__fixtures__/setweight-steps';
import analysisSteps from './__fixtures__/analysis-steps';
import { renderInTestApp } from '@backstage/test-utils';
import { StepsProgress } from './StepsProgress';
describe('StepsProgress', () => {
it('should render Pause step text', async () => {
await renderInTestApp(
<StepsProgress currentStepIndex={0} aborted={false} steps={pauseSteps} />,
);
expect(screen.getByText('pause for 1h')).toBeInTheDocument();
expect(screen.getByText('infinite pause')).toBeInTheDocument();
});
it('should render SetWeight step text', async () => {
await renderInTestApp(
<StepsProgress
currentStepIndex={0}
aborted={false}
steps={setWeightSteps}
/>,
);
expect(screen.getByText('setWeight 10%')).toBeInTheDocument();
expect(screen.getByText('setWeight 95%')).toBeInTheDocument();
});
it('should render Analysis step text', async () => {
await renderInTestApp(
<StepsProgress
currentStepIndex={0}
aborted={false}
steps={analysisSteps}
/>,
);
expect(screen.getAllByText('analysis templates:')).toHaveLength(2);
expect(screen.getByText('always-pass')).toBeInTheDocument();
expect(screen.getByText('always-fail')).toBeInTheDocument();
expect(screen.getByText('req-rate (cluster scoped)')).toBeInTheDocument();
});
it('should render 3 different steps', async () => {
await renderInTestApp(
<StepsProgress
currentStepIndex={0}
aborted={false}
steps={[setWeightSteps[0], pauseSteps[0], analysisSteps[0]]}
/>,
);
expect(screen.getByText('setWeight 10%')).toBeInTheDocument();
expect(screen.getByText('pause for 1h')).toBeInTheDocument();
expect(screen.getByText('analysis templates:')).toBeInTheDocument();
expect(screen.getByText('always-pass')).toBeInTheDocument();
expect(screen.getByText('Canary promoted')).toBeInTheDocument();
});
it('current step is highlighted, previous steps are ticked', async () => {
await renderInTestApp(
<StepsProgress
currentStepIndex={1}
aborted={false}
steps={[setWeightSteps[0], pauseSteps[0], analysisSteps[0]]}
/>,
);
// It is ticked, so it's not visible
expect(screen.queryByText('1')).toBeNull();
// The current step
expect(screen.getByText('2')).toBeInTheDocument();
// The future step
expect(screen.getByText('3')).toBeInTheDocument();
// The canary promoted step should always be added at the end
expect(screen.getByText('4')).toBeInTheDocument();
});
it('aborted canary has all steps grey', async () => {
await renderInTestApp(
<StepsProgress
currentStepIndex={2}
aborted
steps={[setWeightSteps[0], pauseSteps[0], analysisSteps[0]]}
/>,
);
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
});
it('promoted canary has all steps ticked', async () => {
await renderInTestApp(
<StepsProgress
currentStepIndex={3}
aborted={false}
steps={[setWeightSteps[0], pauseSteps[0], analysisSteps[0]]}
/>,
);
expect(screen.queryByText('1')).toBeNull();
expect(screen.queryByText('2')).toBeNull();
expect(screen.queryByText('3')).toBeNull();
expect(screen.queryByText('4')).toBeNull();
});
});
@@ -1,97 +0,0 @@
/*
* Copyright 2020 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 { Step, StepLabel, Stepper } from '@material-ui/core';
import Typography from '@material-ui/core/Typography';
import {
ArgoRolloutCanaryStep,
SetWeightStep,
PauseStep,
AnalysisStep,
} from './types';
interface StepsProgressProps {
currentStepIndex: number;
aborted: boolean;
steps: ArgoRolloutCanaryStep[];
children?: React.ReactNode;
}
const isSetWeightStep = (step: ArgoRolloutCanaryStep): step is SetWeightStep =>
step.hasOwnProperty('setWeight');
const isPauseStep = (step: ArgoRolloutCanaryStep): step is PauseStep =>
step.hasOwnProperty('pause');
const isAnalysisStep = (step: ArgoRolloutCanaryStep): step is AnalysisStep =>
step.hasOwnProperty('analysis');
const createLabelForStep = (step: ArgoRolloutCanaryStep): React.ReactNode => {
if (isSetWeightStep(step)) {
return `setWeight ${step.setWeight}%`;
} else if (isPauseStep(step)) {
return step.pause.duration === undefined
? 'infinite pause'
: `pause for ${step.pause.duration}`;
} else if (isAnalysisStep(step)) {
return (
<div>
<Typography paragraph>analysis templates:</Typography>
{step.analysis.templates.map((t, i) => (
<Typography paragraph key={i}>{`${t.templateName}${
t.clusterScope ? ' (cluster scoped)' : ''
}`}</Typography>
))}
</div>
);
}
return 'unknown step';
};
export const StepsProgress = ({
currentStepIndex,
aborted,
steps,
}: StepsProgressProps) => {
// If the activeStep is greater/equal to the number of steps
// Then the canary is being promoted
// Increase the step index to mark the 'canary promoted' step as done also
const activeStepIndex =
currentStepIndex >= steps.length ? currentStepIndex + 1 : currentStepIndex;
/*
* When the Rollout is aborted set the active step to -1
* otherwise it appears to always be on the first step
*/
return (
<Stepper activeStep={aborted ? -1 : activeStepIndex} alternativeLabel>
{steps
.map((step, i) => (
<Step key={i}>
<StepLabel data-testid={`step-${i}`}>
{createLabelForStep(step)}
</StepLabel>
</Step>
))
.concat(
<Step key="-1">
<StepLabel data-testid="step--1">Canary promoted</StepLabel>
</Step>,
)}
</Stepper>
);
};
@@ -1,129 +0,0 @@
{
"apiVersion": "argoproj.io/v1alpha1",
"kind": "Rollout",
"metadata": {
"annotations": {
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"argoproj.io/v1alpha1\",\"kind\":\"Rollout\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"minReadySeconds\":30,\"replicas\":4,\"selector\":{\"matchLabels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\"}},\"strategy\":{\"canary\":{\"maxSurge\":\"25%\",\"maxUnavailable\":0,\"steps\":[{\"setWeight\":10},{\"analysis\":{\"templates\":[{\"templateName\":\"always-pass\"}]}},{\"pause\":{\"duration\":\"1m\"}},{\"setWeight\":20},{\"pause\":{\"duration\":\"1m\"}},{\"analysis\":{\"templates\":[{\"templateName\":\"always-pass\"}]}}]}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\",\"start\":\"1234\"}},\"spec\":{\"containers\":[{\"image\":\"nginx:1.15.4\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}]}]}}}}\n",
"rollout.argoproj.io/revision": "4"
},
"creationTimestamp": "2021-03-08T10:38:23Z",
"generation": 12,
"labels": {
"backstage.io/kubernetes-id": "dice-roller"
},
"name": "dice-roller",
"namespace": "default",
"resourceVersion": "3336911046",
"selfLink": "/apis/argoproj.io/v1alpha1/namespaces/default/rollouts/dice-roller",
"uid": "8552f08d-32e8-4f95-a43f-8524763eeg60"
},
"spec": {
"minReadySeconds": 30,
"replicas": 4,
"selector": {
"matchLabels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller"
}
},
"strategy": {
"canary": {
"maxSurge": "25%",
"maxUnavailable": 0,
"steps": [
{
"setWeight": 10
},
{
"analysis": {
"templates": [
{
"templateName": "always-pass"
}
]
}
},
{
"pause": {
"duration": "1h"
}
},
{
"setWeight": 20
},
{
"pause": {
"duration": "1m"
}
},
{
"analysis": {
"templates": [
{
"templateName": "always-pass"
}
]
}
}
]
}
},
"template": {
"metadata": {
"creationTimestamp": null,
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"start": "1236"
}
},
"spec": {
"containers": [
{
"image": "nginx:1.15.4",
"name": "nginx",
"ports": [
{
"containerPort": 80
}
],
"resources": {}
}
]
}
}
},
"status": {
"HPAReplicas": 4,
"availableReplicas": 4,
"blueGreen": {},
"canary": {},
"conditions": [
{
"lastTransitionTime": "2021-03-08T10:39:31Z",
"lastUpdateTime": "2021-03-08T10:39:31Z",
"message": "Rollout has minimum availability",
"reason": "AvailableReason",
"status": "True",
"type": "Available"
},
{
"lastTransitionTime": "2021-03-09T16:11:14Z",
"lastUpdateTime": "2021-03-09T16:13:09Z",
"message": "some metric related failure message",
"reason": "RolloutAborted",
"status": "False",
"type": "Progressing"
}
],
"currentPodHash": "546c476497",
"currentStepHash": "5b48bb87dc",
"currentStepIndex": 0,
"observedGeneration": "12",
"readyReplicas": 4,
"replicas": 4,
"selector": "app=dice-roller,backstage.io/kubernetes-id=dice-roller",
"stableRS": "546c476497",
"updatedReplicas": 4
}
}
@@ -1,43 +0,0 @@
/*
* Copyright 2021 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 { AnalysisStep } from '../types';
export const steps: AnalysisStep[] = [
{
analysis: {
templates: [
{
templateName: 'always-pass',
},
{
templateName: 'always-fail',
},
],
},
},
{
analysis: {
templates: [
{
templateName: 'req-rate',
clusterScope: true,
},
],
},
},
];
export default steps;
@@ -1,576 +0,0 @@
{
"pods": [
{
"metadata": {
"creationTimestamp": "2020-09-25T09:58:50.000Z",
"generateName": "dice-roller-6c8646bfd-",
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"pod-template-hash": "6c8646bfd"
},
"managedFields": [
{
"apiVersion": "v1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:metadata": {
"f:generateName": {},
"f:labels": {
".": {},
"f:app": {},
"f:backstage.io/kubernetes-id": {},
"f:pod-template-hash": {}
},
"f:ownerReferences": {
".": {},
"k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": {
".": {},
"f:apiVersion": {},
"f:blockOwnerDeletion": {},
"f:controller": {},
"f:kind": {},
"f:name": {},
"f:uid": {}
}
}
},
"f:spec": {
"f:containers": {
"k:{\"name\":\"nginx\"}": {
".": {},
"f:image": {},
"f:imagePullPolicy": {},
"f:name": {},
"f:ports": {
".": {},
"k:{\"containerPort\":80,\"protocol\":\"TCP\"}": {
".": {},
"f:containerPort": {},
"f:protocol": {}
}
},
"f:resources": {},
"f:terminationMessagePath": {},
"f:terminationMessagePolicy": {}
}
},
"f:dnsPolicy": {},
"f:enableServiceLinks": {},
"f:restartPolicy": {},
"f:schedulerName": {},
"f:securityContext": {},
"f:terminationGracePeriodSeconds": {}
}
},
"manager": "kube-controller-manager",
"operation": "Update",
"time": "2020-09-25T09:58:50.000Z"
},
{
"apiVersion": "v1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:status": {
"f:conditions": {
"k:{\"type\":\"ContainersReady\"}": {
".": {},
"f:lastProbeTime": {},
"f:lastTransitionTime": {},
"f:status": {},
"f:type": {}
},
"k:{\"type\":\"Initialized\"}": {
".": {},
"f:lastProbeTime": {},
"f:lastTransitionTime": {},
"f:status": {},
"f:type": {}
},
"k:{\"type\":\"Ready\"}": {
".": {},
"f:lastProbeTime": {},
"f:lastTransitionTime": {},
"f:status": {},
"f:type": {}
}
},
"f:containerStatuses": {},
"f:hostIP": {},
"f:phase": {},
"f:podIP": {},
"f:podIPs": {
".": {},
"k:{\"ip\":\"172.17.0.11\"}": {
".": {},
"f:ip": {}
}
},
"f:startTime": {}
}
},
"manager": "kubelet",
"operation": "Update",
"time": "2020-09-25T09:58:55.000Z"
}
],
"name": "dice-roller-6c8646bfd-2m5hv",
"namespace": "default",
"ownerReferences": [
{
"apiVersion": "apps/v1",
"blockOwnerDeletion": true,
"controller": true,
"kind": "ReplicaSet",
"name": "dice-roller-6c8646bfd",
"uid": "5126c354-4310-4e23-a9e4-c9b87cb69792"
}
],
"resourceVersion": "593216",
"selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-2m5hv",
"uid": "aadb71c0-36fa-43e3-b38a-162f134d4359"
},
"spec": {
"containers": [
{
"image": "nginx:1.14.2",
"imagePullPolicy": "IfNotPresent",
"name": "nginx",
"ports": [
{
"containerPort": 80,
"protocol": "TCP"
}
],
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File",
"volumeMounts": [
{
"mountPath": "/var/run/secrets/kubernetes.io/serviceaccount",
"name": "default-token-5gctn",
"readOnly": true
}
]
}
],
"dnsPolicy": "ClusterFirst",
"enableServiceLinks": true,
"nodeName": "minikube",
"priority": 0,
"restartPolicy": "Always",
"schedulerName": "default-scheduler",
"securityContext": {},
"serviceAccount": "default",
"serviceAccountName": "default",
"terminationGracePeriodSeconds": 30,
"tolerations": [
{
"effect": "NoExecute",
"key": "node.kubernetes.io/not-ready",
"operator": "Exists",
"tolerationSeconds": 300
},
{
"effect": "NoExecute",
"key": "node.kubernetes.io/unreachable",
"operator": "Exists",
"tolerationSeconds": 300
}
],
"volumes": [
{
"name": "default-token-5gctn",
"secret": {
"defaultMode": 420,
"secretName": "default-token-5gctn"
}
}
]
},
"status": {
"conditions": [
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:50.000Z",
"status": "True",
"type": "Initialized"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:55.000Z",
"status": "True",
"type": "Ready"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:55.000Z",
"status": "True",
"type": "ContainersReady"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:50.000Z",
"status": "True",
"type": "PodScheduled"
}
],
"containerStatuses": [
{
"containerID": "docker://aa4489297c34c48bb33c18474a8d2b33854a82ed42155680b259f635f556ce70",
"image": "nginx:1.14.2",
"imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d",
"lastState": {},
"name": "nginx",
"ready": true,
"restartCount": 0,
"started": true,
"state": {
"running": {
"startedAt": "2020-09-25T09:58:53.000Z"
}
}
}
],
"hostIP": "192.168.64.2",
"phase": "Running",
"podIP": "172.17.0.11",
"podIPs": [
{
"ip": "172.17.0.11"
}
],
"qosClass": "BestEffort",
"startTime": "2020-09-25T09:58:50.000Z"
}
},
{
"metadata": {
"creationTimestamp": "2020-09-25T09:58:50.000Z",
"generateName": "dice-roller-6c8646bfd-",
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"pod-template-hash": "6c8646bfd"
},
"managedFields": [
{
"apiVersion": "v1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:metadata": {
"f:generateName": {},
"f:labels": {
".": {},
"f:app": {},
"f:backstage.io/kubernetes-id": {},
"f:pod-template-hash": {}
},
"f:ownerReferences": {
".": {},
"k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": {
".": {},
"f:apiVersion": {},
"f:blockOwnerDeletion": {},
"f:controller": {},
"f:kind": {},
"f:name": {},
"f:uid": {}
}
}
},
"f:spec": {
"f:containers": {
"k:{\"name\":\"nginx\"}": {
".": {},
"f:image": {},
"f:imagePullPolicy": {},
"f:name": {},
"f:ports": {
".": {},
"k:{\"containerPort\":80,\"protocol\":\"TCP\"}": {
".": {},
"f:containerPort": {},
"f:protocol": {}
}
},
"f:resources": {},
"f:terminationMessagePath": {},
"f:terminationMessagePolicy": {}
}
},
"f:dnsPolicy": {},
"f:enableServiceLinks": {},
"f:restartPolicy": {},
"f:schedulerName": {},
"f:securityContext": {},
"f:terminationGracePeriodSeconds": {}
}
},
"manager": "kube-controller-manager",
"operation": "Update",
"time": "2020-09-25T09:58:50.000Z"
},
{
"apiVersion": "v1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:status": {
"f:conditions": {
"k:{\"type\":\"ContainersReady\"}": {
".": {},
"f:lastProbeTime": {},
"f:lastTransitionTime": {},
"f:status": {},
"f:type": {}
},
"k:{\"type\":\"Initialized\"}": {
".": {},
"f:lastProbeTime": {},
"f:lastTransitionTime": {},
"f:status": {},
"f:type": {}
},
"k:{\"type\":\"Ready\"}": {
".": {},
"f:lastProbeTime": {},
"f:lastTransitionTime": {},
"f:status": {},
"f:type": {}
}
},
"f:containerStatuses": {},
"f:hostIP": {},
"f:phase": {},
"f:podIP": {},
"f:podIPs": {
".": {},
"k:{\"ip\":\"172.17.0.7\"}": {
".": {},
"f:ip": {}
}
},
"f:startTime": {}
}
},
"manager": "kubelet",
"operation": "Update",
"time": "2020-09-25T09:58:55.000Z"
}
],
"name": "dice-roller-6c8646bfd-4v6s8",
"namespace": "default",
"ownerReferences": [
{
"apiVersion": "apps/v1",
"blockOwnerDeletion": true,
"controller": true,
"kind": "ReplicaSet",
"name": "dice-roller-6c8646bfd",
"uid": "5126c354-4310-4e23-a9e4-c9b87cb69792"
}
],
"resourceVersion": "593221",
"selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-4v6s8",
"uid": "32e56490-6f20-4757-991f-a0c7fb407358"
},
"spec": {
"containers": [
{
"image": "nginx:1.14.2",
"imagePullPolicy": "IfNotPresent",
"name": "nginx",
"ports": [
{
"containerPort": 80,
"protocol": "TCP"
}
],
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File",
"volumeMounts": [
{
"mountPath": "/var/run/secrets/kubernetes.io/serviceaccount",
"name": "default-token-5gctn",
"readOnly": true
}
]
}
],
"dnsPolicy": "ClusterFirst",
"enableServiceLinks": true,
"nodeName": "minikube",
"priority": 0,
"restartPolicy": "Always",
"schedulerName": "default-scheduler",
"securityContext": {},
"serviceAccount": "default",
"serviceAccountName": "default",
"terminationGracePeriodSeconds": 30,
"tolerations": [
{
"effect": "NoExecute",
"key": "node.kubernetes.io/not-ready",
"operator": "Exists",
"tolerationSeconds": 300
},
{
"effect": "NoExecute",
"key": "node.kubernetes.io/unreachable",
"operator": "Exists",
"tolerationSeconds": 300
}
],
"volumes": [
{
"name": "default-token-5gctn",
"secret": {
"defaultMode": 420,
"secretName": "default-token-5gctn"
}
}
]
},
"status": {
"conditions": [
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:50.000Z",
"status": "True",
"type": "Initialized"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:55.000Z",
"status": "True",
"type": "Ready"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:55.000Z",
"status": "True",
"type": "ContainersReady"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:50.000Z",
"status": "True",
"type": "PodScheduled"
}
],
"containerStatuses": [
{
"containerID": "docker://d91cc76c41249b8d3dfcf538d180b471b2a7966aae0d17a818307c8a9b4af897",
"image": "nginx:1.14.2",
"imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d",
"lastState": {},
"name": "nginx",
"ready": true,
"restartCount": 0,
"started": true,
"state": {
"running": {
"startedAt": "2020-09-25T09:58:53.000Z"
}
}
}
],
"hostIP": "192.168.64.2",
"phase": "Running",
"podIP": "172.17.0.7",
"podIPs": [
{
"ip": "172.17.0.7"
}
],
"qosClass": "BestEffort",
"startTime": "2020-09-25T09:58:50.000Z"
}
}
],
"horizontalPodAutoscalers": [],
"replicaSets": [
{
"metadata": {
"annotations": {
"deployment.kubernetes.io/desired-replicas": "10",
"deployment.kubernetes.io/max-replicas": "13",
"deployment.kubernetes.io/revision": "2"
},
"creationTimestamp": "2020-09-24T11:39:26.000Z",
"generation": 3,
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"pod-template-hash": "6c8646bfd"
},
"managedFields": [],
"name": "dice-roller-6c8646bfd",
"namespace": "default",
"ownerReferences": [
{
"apiVersion": "apps/v1",
"blockOwnerDeletion": true,
"controller": true,
"kind": "Rollout",
"name": "dice-roller",
"uid": "8552f08d-32e8-4f95-a43f-8524763eeg60"
}
],
"resourceVersion": "593228",
"selfLink": "/apis/apps/v1/namespaces/default/replicasets/dice-roller-6c8646bfd",
"uid": "5126c354-4310-4e23-a9e4-c9b87cb69792"
},
"spec": {
"replicas": 10,
"selector": {
"matchLabels": {
"app": "dice-roller",
"pod-template-hash": "6c8646bfd"
}
},
"template": {
"metadata": {
"creationTimestamp": null,
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"pod-template-hash": "6c8646bfd"
}
},
"spec": {
"containers": [
{
"image": "nginx:1.14.2",
"imagePullPolicy": "IfNotPresent",
"name": "nginx",
"ports": [
{
"containerPort": 80,
"protocol": "TCP"
}
],
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File"
}
],
"dnsPolicy": "ClusterFirst",
"restartPolicy": "Always",
"schedulerName": "default-scheduler",
"securityContext": {},
"terminationGracePeriodSeconds": 30
}
}
},
"status": {
"availableReplicas": 10,
"fullyLabeledReplicas": 10,
"observedGeneration": 3,
"readyReplicas": 10,
"replicas": 10
}
}
]
}
@@ -1,29 +0,0 @@
/*
* Copyright 2021 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 { PauseStep } from '../types';
export const steps: PauseStep[] = [
{
pause: {
duration: '1h',
},
},
{
pause: {},
},
];
export default steps;
@@ -1,135 +0,0 @@
{
"apiVersion": "argoproj.io/v1alpha1",
"kind": "Rollout",
"metadata": {
"annotations": {
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"argoproj.io/v1alpha1\",\"kind\":\"Rollout\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"minReadySeconds\":30,\"replicas\":4,\"selector\":{\"matchLabels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\"}},\"strategy\":{\"canary\":{\"maxSurge\":\"25%\",\"maxUnavailable\":0,\"steps\":[{\"setWeight\":10},{\"analysis\":{\"templates\":[{\"templateName\":\"always-pass\"}]}},{\"pause\":{\"duration\":\"1m\"}},{\"setWeight\":20},{\"pause\":{\"duration\":\"1m\"}},{\"analysis\":{\"templates\":[{\"templateName\":\"always-pass\"}]}}]}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\",\"start\":\"1234\"}},\"spec\":{\"containers\":[{\"image\":\"nginx:1.15.4\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}]}]}}}}\n",
"rollout.argoproj.io/revision": "4"
},
"creationTimestamp": "2021-03-08T10:38:23Z",
"generation": 12,
"labels": {
"backstage.io/kubernetes-id": "dice-roller"
},
"name": "dice-roller",
"namespace": "default",
"resourceVersion": "3336911046",
"selfLink": "/apis/argoproj.io/v1alpha1/namespaces/default/rollouts/dice-roller",
"uid": "8552f08d-32e8-4f95-a43f-8524763eeg60"
},
"spec": {
"minReadySeconds": 30,
"replicas": 4,
"selector": {
"matchLabels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller"
}
},
"strategy": {
"canary": {
"maxSurge": "25%",
"maxUnavailable": 0,
"steps": [
{
"setWeight": 10
},
{
"analysis": {
"templates": [
{
"templateName": "always-pass"
}
]
}
},
{
"pause": {
"duration": "1h"
}
},
{
"setWeight": 20
},
{
"pause": {
"duration": "1m"
}
},
{
"analysis": {
"templates": [
{
"templateName": "always-pass"
}
]
}
}
]
}
},
"template": {
"metadata": {
"creationTimestamp": null,
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"start": "1236"
}
},
"spec": {
"containers": [
{
"image": "nginx:1.15.4",
"name": "nginx",
"ports": [
{
"containerPort": 80
}
],
"resources": {}
}
]
}
}
},
"status": {
"HPAReplicas": 4,
"availableReplicas": 4,
"blueGreen": {},
"canary": {},
"pauseConditions": [
{
"reason": "CanaryPauseStep",
"startTime": "SET DYNAMICALLY IN TEST"
}
],
"conditions": [
{
"lastTransitionTime": "2021-03-08T10:39:31Z",
"lastUpdateTime": "2021-03-08T10:39:31Z",
"message": "Rollout has minimum availability",
"reason": "AvailableReason",
"status": "True",
"type": "Available"
},
{
"lastTransitionTime": "2021-03-09T16:11:14Z",
"lastUpdateTime": "2021-03-09T16:13:09Z",
"message": "ReplicaSet \"dice-roller-546c476497\" has successfully progressed.",
"reason": "NewReplicaSetAvailable",
"status": "True",
"type": "Progressing"
}
],
"currentPodHash": "546c476497",
"currentStepHash": "5b48bb87dc",
"currentStepIndex": 2,
"observedGeneration": "12",
"readyReplicas": 4,
"replicas": 4,
"selector": "app=dice-roller,backstage.io/kubernetes-id=dice-roller",
"stableRS": "546c476497",
"updatedReplicas": 4
}
}
@@ -1,129 +0,0 @@
{
"apiVersion": "argoproj.io/v1alpha1",
"kind": "Rollout",
"metadata": {
"annotations": {
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"argoproj.io/v1alpha1\",\"kind\":\"Rollout\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"minReadySeconds\":30,\"replicas\":4,\"selector\":{\"matchLabels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\"}},\"strategy\":{\"canary\":{\"maxSurge\":\"25%\",\"maxUnavailable\":0,\"steps\":[{\"setWeight\":10},{\"analysis\":{\"templates\":[{\"templateName\":\"always-pass\"}]}},{\"pause\":{\"duration\":\"1m\"}},{\"setWeight\":20},{\"pause\":{\"duration\":\"1m\"}},{\"analysis\":{\"templates\":[{\"templateName\":\"always-pass\"}]}}]}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\",\"start\":\"1234\"}},\"spec\":{\"containers\":[{\"image\":\"nginx:1.15.4\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}]}]}}}}\n",
"rollout.argoproj.io/revision": "4"
},
"creationTimestamp": "2021-03-08T10:38:23Z",
"generation": 12,
"labels": {
"backstage.io/kubernetes-id": "dice-roller"
},
"name": "dice-roller",
"namespace": "default",
"resourceVersion": "3336911046",
"selfLink": "/apis/argoproj.io/v1alpha1/namespaces/default/rollouts/dice-roller",
"uid": "8552f08d-32e8-4f95-a43f-8524763eeg60"
},
"spec": {
"minReadySeconds": 30,
"replicas": 4,
"selector": {
"matchLabels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller"
}
},
"strategy": {
"canary": {
"maxSurge": "25%",
"maxUnavailable": 0,
"steps": [
{
"setWeight": 10
},
{
"analysis": {
"templates": [
{
"templateName": "always-pass"
}
]
}
},
{
"pause": {
"duration": "1h"
}
},
{
"setWeight": 20
},
{
"pause": {
"duration": "1m"
}
},
{
"analysis": {
"templates": [
{
"templateName": "always-pass"
}
]
}
}
]
}
},
"template": {
"metadata": {
"creationTimestamp": null,
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"start": "1236"
}
},
"spec": {
"containers": [
{
"image": "nginx:1.15.4",
"name": "nginx",
"ports": [
{
"containerPort": 80
}
],
"resources": {}
}
]
}
}
},
"status": {
"HPAReplicas": 4,
"availableReplicas": 4,
"blueGreen": {},
"canary": {},
"conditions": [
{
"lastTransitionTime": "2021-03-08T10:39:31Z",
"lastUpdateTime": "2021-03-08T10:39:31Z",
"message": "Rollout has minimum availability",
"reason": "AvailableReason",
"status": "True",
"type": "Available"
},
{
"lastTransitionTime": "2021-03-09T16:11:14Z",
"lastUpdateTime": "2021-03-09T16:13:09Z",
"message": "ReplicaSet \"dice-roller-546c476497\" has successfully progressed.",
"reason": "NewReplicaSetAvailable",
"status": "True",
"type": "Progressing"
}
],
"currentPodHash": "546c476497",
"currentStepHash": "5b48bb87dc",
"currentStepIndex": 6,
"observedGeneration": "12",
"readyReplicas": 4,
"replicas": 4,
"selector": "app=dice-roller,backstage.io/kubernetes-id=dice-roller",
"stableRS": "546c476497",
"updatedReplicas": 4
}
}
@@ -1,27 +0,0 @@
/*
* Copyright 2021 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 { SetWeightStep } from '../types';
export const steps: SetWeightStep[] = [
{
setWeight: 10,
},
{
setWeight: 95,
},
];
export default steps;
@@ -1,16 +0,0 @@
/*
* Copyright 2020 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 './Rollout';
@@ -1,36 +0,0 @@
/*
* Copyright 2020 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 interface SetWeightStep {
setWeight: number;
}
export interface PauseStep {
pause: {
duration?: string;
};
}
export interface AnalysisStep {
analysis: {
templates: {
templateName: string;
clusterScope?: boolean;
}[];
};
}
export type ArgoRolloutCanaryStep = SetWeightStep | PauseStep | AnalysisStep;
@@ -1,55 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useContext } from 'react';
import lodash, { Dictionary } from 'lodash';
import { RolloutAccordions } from './ArgoRollouts';
import { DefaultCustomResourceAccordions } from './DefaultCustomResource';
import { GroupedResponsesContext } from '../../hooks';
interface CustomResourcesProps {
children?: React.ReactNode;
}
const kindToResource = (customResources: any[]): Dictionary<any[]> => {
return lodash.groupBy(customResources, value => {
return value.kind;
});
};
export const CustomResources = ({}: CustomResourcesProps) => {
const groupedResponses = useContext(GroupedResponsesContext);
const kindToResourceMap = kindToResource(groupedResponses.customResources);
return (
<>
{Object.entries(kindToResourceMap).map(([kind, resources], i) => {
switch (kind) {
case 'Rollout':
return <RolloutAccordions key={i} rollouts={resources} />;
default:
return (
<DefaultCustomResourceAccordions
key={i}
customResources={resources}
customResourceName={kind}
/>
);
}
})}
</>
);
};
@@ -1,39 +0,0 @@
/*
* Copyright 2021 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 { screen } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import { kubernetesProviders } from '../../hooks/test-utils';
import * as ar from './__fixtures__/analysis-run.json';
import { DefaultCustomResourceAccordions } from './DefaultCustomResource';
describe('DefaultCustomResource', () => {
it('should render DefaultCustomResource Accordion', async () => {
const wrapper = kubernetesProviders({}, new Set([]));
await renderInTestApp(
wrapper(
<DefaultCustomResourceAccordions
customResources={[ar] as any}
customResourceName="AnalysisRun"
/>,
),
);
expect(screen.getByText('dice-roller-546c476497-4-1')).toBeInTheDocument();
expect(screen.getByText('AnalysisRun')).toBeInTheDocument();
});
});
@@ -1,121 +0,0 @@
/*
* Copyright 2021 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 {
Accordion,
AccordionDetails,
AccordionSummary,
Grid,
} from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { DefaultCustomResourceDrawer } from './DefaultCustomResourceDrawer';
import { StructuredMetadataTable } from '@backstage/core-components';
type DefaultCustomResourceAccordionsProps = {
customResources: any[];
customResourceName: string;
defaultExpanded?: boolean;
children?: React.ReactNode;
};
type DefaultCustomResourceAccordionProps = {
customResource: any;
customResourceName: string;
defaultExpanded?: boolean;
children?: React.ReactNode;
};
type DefaultCustomResourceSummaryProps = {
customResource: any;
customResourceName: string;
children?: React.ReactNode;
};
const DefaultCustomResourceSummary = ({
customResource,
customResourceName,
}: DefaultCustomResourceSummaryProps) => {
return (
<Grid
container
direction="row"
justifyContent="space-between"
alignItems="center"
spacing={0}
>
<Grid xs={12} item>
<DefaultCustomResourceDrawer
customResource={customResource}
customResourceName={customResourceName}
/>
</Grid>
</Grid>
);
};
const DefaultCustomResourceAccordion = ({
customResource,
customResourceName,
defaultExpanded,
}: DefaultCustomResourceAccordionProps) => {
return (
<Accordion
defaultExpanded={defaultExpanded}
TransitionProps={{ unmountOnExit: true }}
variant="outlined"
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<DefaultCustomResourceSummary
customResource={customResource}
customResourceName={customResourceName}
/>
</AccordionSummary>
<AccordionDetails>
{customResource.hasOwnProperty('status') && (
<StructuredMetadataTable metadata={customResource.status} />
)}
</AccordionDetails>
</Accordion>
);
};
export const DefaultCustomResourceAccordions = ({
customResources,
customResourceName,
defaultExpanded = false,
}: DefaultCustomResourceAccordionsProps) => {
return (
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
>
{customResources.map((cr, i) => (
<Grid container item key={i} xs>
<Grid item xs>
<DefaultCustomResourceAccordion
defaultExpanded={defaultExpanded}
customResource={cr}
customResourceName={customResourceName}
/>
</Grid>
</Grid>
))}
</Grid>
);
};
@@ -1,62 +0,0 @@
/*
* Copyright 2021 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 { KubernetesStructuredMetadataTableDrawer } from '../KubernetesDrawer';
import { Typography, Grid } from '@material-ui/core';
const capitalize = (str: string) =>
str.charAt(0).toLocaleUpperCase('en-US') + str.slice(1);
export const DefaultCustomResourceDrawer = ({
customResource,
customResourceName,
expanded,
}: {
customResource: any;
customResourceName: string;
expanded?: boolean;
}) => {
const capitalizedName = capitalize(customResourceName);
return (
<KubernetesStructuredMetadataTableDrawer
object={customResource}
expanded={expanded}
kind={capitalizedName}
renderObject={cr => cr}
>
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
spacing={0}
>
<Grid item>
<Typography variant="body1">
{customResource.metadata?.name ?? 'unknown object'}
</Typography>
</Grid>
<Grid item>
<Typography color="textSecondary" variant="subtitle1">
{capitalizedName}
</Typography>
</Grid>
</Grid>
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -1,129 +0,0 @@
{
"apiVersion": "argoproj.io/v1alpha1",
"kind": "AnalysisRun",
"metadata": {
"annotations": {
"rollout.argoproj.io/revision": "4"
},
"creationTimestamp": "2021-03-09T15:05:49Z",
"generation": 11,
"labels": {
"backstage.io/kubernetes-id": "dice-roller",
"rollout-type": "Step",
"rollouts-pod-template-hash": "546c476497",
"step-index": "1"
},
"name": "dice-roller-546c476497-4-1",
"namespace": "default",
"ownerReferences": [
{
"apiVersion": "argoproj.io/v1alpha1",
"blockOwnerDeletion": true,
"controller": true,
"kind": "Rollout",
"name": "dice-roller",
"uid": "8552f08d-32e8-4f95-a43f-8524763eef60"
}
],
"resourceVersion": "3342462005",
"selfLink": "/apis/argoproj.io/v1alpha1/namespaces/default/analysisruns/dice-roller-546c476497-4-1",
"uid": "b4e720ea-0488-42e2-8bd9-924c78843ee2"
},
"spec": {
"metrics": [
{
"count": 5,
"interval": "5s",
"name": "always-pass",
"provider": {
"job": {
"metadata": {
"creationTimestamp": null
},
"spec": {
"backoffLimit": 1,
"template": {
"metadata": {
"creationTimestamp": null
},
"spec": {
"containers": [
{
"image": "some-image",
"name": "always-pass",
"resources": {
"limits": {
"cpu": "800m",
"memory": "1G"
},
"requests": {
"cpu": "200m",
"memory": "1G"
}
}
}
],
"restartPolicy": "Never"
}
}
}
}
}
}
]
},
"status": {
"metricResults": [
{
"count": 5,
"measurements": [
{
"finishedAt": "2021-03-09T15:06:13Z",
"metadata": {
"job-name": "b4e720ea-0488-42e2-8bd9-924c78843ee2.always-pass.1"
},
"phase": "Successful",
"startedAt": "2021-03-09T15:05:49Z"
},
{
"finishedAt": "2021-03-09T15:06:41Z",
"metadata": {
"job-name": "b4e720ea-0488-42e2-8bd9-924c78843ee2.always-pass.2"
},
"phase": "Successful",
"startedAt": "2021-03-09T15:06:18Z"
},
{
"finishedAt": "2021-03-09T15:07:08Z",
"metadata": {
"job-name": "b4e720ea-0488-42e2-8bd9-924c78843ee2.always-pass.3"
},
"phase": "Successful",
"startedAt": "2021-03-09T15:06:46Z"
},
{
"finishedAt": "2021-03-09T15:07:35Z",
"metadata": {
"job-name": "b4e720ea-0488-42e2-8bd9-924c78843ee2.always-pass.4"
},
"phase": "Successful",
"startedAt": "2021-03-09T15:07:13Z"
},
{
"finishedAt": "2021-03-09T15:08:02Z",
"metadata": {
"job-name": "b4e720ea-0488-42e2-8bd9-924c78843ee2.always-pass.5"
},
"phase": "Successful",
"startedAt": "2021-03-09T15:07:40Z"
}
],
"name": "always-pass",
"phase": "Successful",
"successful": 5
}
],
"phase": "Successful",
"startedAt": "2021-03-09T15:05:49Z"
}
}
@@ -1,16 +0,0 @@
/*
* Copyright 2020 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 { CustomResources } from './CustomResources';
@@ -1,67 +0,0 @@
/*
* Copyright 2020 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 * as deployments from '../../__fixtures__/2-deployments.json';
import { renderInTestApp, textContentMatcher } from '@backstage/test-utils';
import { DeploymentDrawer } from './DeploymentDrawer';
describe('DeploymentDrawer', () => {
it('should render deployment drawer', async () => {
const { getByText, getAllByText } = await renderInTestApp(
<DeploymentDrawer
deployment={(deployments as any).deployments[0]}
expanded
/>,
);
expect(getAllByText('dice-roller')).toHaveLength(2);
expect(getAllByText('Deployment')).toHaveLength(2);
expect(getByText('YAML')).toBeInTheDocument();
expect(getByText('Strategy')).toBeInTheDocument();
expect(getByText('Rolling Update:')).toBeInTheDocument();
expect(getByText(textContentMatcher('Max Surge: 25%'))).toBeInTheDocument();
expect(
getByText(textContentMatcher('Max Unavailable: 25%')),
).toBeInTheDocument();
expect(
getByText(textContentMatcher('Type: RollingUpdate')),
).toBeInTheDocument();
expect(getByText('Min Ready Seconds')).toBeInTheDocument();
expect(getByText('???')).toBeInTheDocument();
expect(getByText('Progress Deadline Seconds')).toBeInTheDocument();
expect(getByText('600')).toBeInTheDocument();
expect(getByText('Progressing')).toBeInTheDocument();
expect(getByText('Available')).toBeInTheDocument();
expect(getByText('namespace: default')).toBeInTheDocument();
expect(getAllByText('True')).toHaveLength(2);
});
it('should render deployment drawer without namespace', async () => {
const deployment = (deployments as any).deployments[0];
const { queryByText } = await renderInTestApp(
<DeploymentDrawer
deployment={{
...deployment,
metadata: { ...deployment.metadata, namespace: undefined },
}}
expanded
/>,
);
expect(queryByText('namespace: default')).not.toBeInTheDocument();
});
});
@@ -1,78 +0,0 @@
/*
* Copyright 2020 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 { V1Deployment } from '@kubernetes/client-node';
import { KubernetesStructuredMetadataTableDrawer } from '../KubernetesDrawer';
import { renderCondition } from '../../utils/pod';
import { Typography, Grid, Chip } from '@material-ui/core';
export const DeploymentDrawer = ({
deployment,
expanded,
}: {
deployment: V1Deployment;
expanded?: boolean;
}) => {
const namespace = deployment.metadata?.namespace;
return (
<KubernetesStructuredMetadataTableDrawer
object={deployment}
expanded={expanded}
kind="Deployment"
renderObject={(deploymentObj: V1Deployment) => {
const conditions = (deploymentObj.status?.conditions ?? [])
.map(renderCondition)
.reduce((accum, next) => {
accum[next[0]] = next[1];
return accum;
}, {} as { [key: string]: React.ReactNode });
return {
strategy: deploymentObj.spec?.strategy ?? '???',
minReadySeconds: deploymentObj.spec?.minReadySeconds ?? '???',
progressDeadlineSeconds:
deploymentObj.spec?.progressDeadlineSeconds ?? '???',
...conditions,
};
}}
>
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
spacing={0}
>
<Grid item>
<Typography variant="body1">
{deployment.metadata?.name ?? 'unknown object'}
</Typography>
</Grid>
<Grid item>
<Typography color="textSecondary" variant="subtitle1">
Deployment
</Typography>
</Grid>
{namespace && (
<Grid item>
<Chip size="small" label={`namespace: ${namespace}`} />
</Grid>
)}
</Grid>
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -1,46 +0,0 @@
/*
* Copyright 2020 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 { screen } from '@testing-library/react';
import { DeploymentsAccordions } from './DeploymentsAccordions';
import * as twoDeployFixture from '../../__fixtures__/2-deployments.json';
import { renderInTestApp } from '@backstage/test-utils';
import { kubernetesProviders } from '../../hooks/test-utils';
describe('DeploymentsAccordions', () => {
it('should render 2 deployments', async () => {
const wrapper = kubernetesProviders(
twoDeployFixture,
new Set(['dice-roller-canary-7d64cd756c-vtbdx']),
);
await renderInTestApp(wrapper(<DeploymentsAccordions />));
expect(screen.getByText('dice-roller')).toBeInTheDocument();
expect(screen.getByText('10 pods')).toBeInTheDocument();
expect(screen.getByText('No pods with errors')).toBeInTheDocument();
expect(
screen.getByText('min replicas 10 / max replicas 15'),
).toBeInTheDocument();
expect(screen.getByText('current CPU usage: 30%')).toBeInTheDocument();
expect(screen.getByText('target CPU usage: 50%')).toBeInTheDocument();
expect(screen.getByText('dice-roller-canary')).toBeInTheDocument();
expect(screen.getByText('2 pods')).toBeInTheDocument();
expect(screen.getByText('1 pod with errors')).toBeInTheDocument();
});
});
@@ -1,206 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useContext } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Grid,
Typography,
} from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import {
V1Deployment,
V1Pod,
V1HorizontalPodAutoscaler,
} from '@kubernetes/client-node';
import { PodsTable } from '../Pods';
import { DeploymentDrawer } from './DeploymentDrawer';
import { HorizontalPodAutoscalerDrawer } from '../HorizontalPodAutoscalers';
import {
getOwnedPodsThroughReplicaSets,
getMatchingHpa,
} from '../../utils/owner';
import {
GroupedResponsesContext,
PodNamesWithErrorsContext,
} from '../../hooks';
import { StatusError, StatusOK } from '@backstage/core-components';
import { READY_COLUMNS, RESOURCE_COLUMNS } from '../Pods/PodsTable';
type DeploymentsAccordionsProps = {
children?: React.ReactNode;
};
type DeploymentAccordionProps = {
deployment: V1Deployment;
ownedPods: V1Pod[];
matchingHpa?: V1HorizontalPodAutoscaler;
children?: React.ReactNode;
};
type DeploymentSummaryProps = {
deployment: V1Deployment;
numberOfCurrentPods: number;
numberOfPodsWithErrors: number;
hpa?: V1HorizontalPodAutoscaler;
children?: React.ReactNode;
};
const DeploymentSummary = ({
deployment,
numberOfCurrentPods,
numberOfPodsWithErrors,
hpa,
}: DeploymentSummaryProps) => {
return (
<Grid
container
direction="row"
justifyContent="space-between"
alignItems="center"
spacing={0}
>
<Grid xs={4} item>
<DeploymentDrawer deployment={deployment} />
</Grid>
{hpa && (
<Grid item xs={4}>
<HorizontalPodAutoscalerDrawer hpa={hpa}>
<Grid
item
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
spacing={0}
>
<Grid item>
<Typography variant="subtitle2">
min replicas {hpa.spec?.minReplicas ?? '?'} / max replicas{' '}
{hpa.spec?.maxReplicas ?? '?'}
</Typography>
</Grid>
<Grid item>
<Typography variant="subtitle2">
current CPU usage:{' '}
{hpa.status?.currentCPUUtilizationPercentage ?? '?'}%
</Typography>
</Grid>
<Grid item>
<Typography variant="subtitle2">
target CPU usage:{' '}
{hpa.spec?.targetCPUUtilizationPercentage ?? '?'}%
</Typography>
</Grid>
</Grid>
</HorizontalPodAutoscalerDrawer>
</Grid>
)}
<Grid
item
container
xs={4}
direction="column"
justifyContent="flex-start"
alignItems="flex-end"
spacing={0}
>
<Grid item>
<StatusOK>{numberOfCurrentPods} pods</StatusOK>
</Grid>
<Grid item>
{numberOfPodsWithErrors > 0 ? (
<StatusError>
{numberOfPodsWithErrors} pod
{numberOfPodsWithErrors > 1 ? 's' : ''} with errors
</StatusError>
) : (
<StatusOK>No pods with errors</StatusOK>
)}
</Grid>
</Grid>
</Grid>
);
};
const DeploymentAccordion = ({
deployment,
ownedPods,
matchingHpa,
}: DeploymentAccordionProps) => {
const podNamesWithErrors = useContext(PodNamesWithErrorsContext);
const podsWithErrors = ownedPods.filter(p =>
podNamesWithErrors.has(p.metadata?.name ?? ''),
);
return (
<Accordion TransitionProps={{ unmountOnExit: true }} variant="outlined">
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<DeploymentSummary
deployment={deployment}
numberOfCurrentPods={ownedPods.length}
numberOfPodsWithErrors={podsWithErrors.length}
hpa={matchingHpa}
/>
</AccordionSummary>
<AccordionDetails>
<PodsTable
pods={ownedPods}
extraColumns={[READY_COLUMNS, RESOURCE_COLUMNS]}
/>
</AccordionDetails>
</Accordion>
);
};
export const DeploymentsAccordions = ({}: DeploymentsAccordionsProps) => {
const groupedResponses = useContext(GroupedResponsesContext);
return (
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
>
{groupedResponses.deployments.map((deployment, i) => (
<Grid container item key={i} xs>
<Grid item xs>
<DeploymentAccordion
matchingHpa={getMatchingHpa(
{
name: deployment.metadata?.name,
namespace: deployment.metadata?.namespace,
kind: 'deployment',
},
groupedResponses.horizontalPodAutoscalers,
)}
ownedPods={getOwnedPodsThroughReplicaSets(
deployment,
groupedResponses.replicaSets,
groupedResponses.pods,
)}
deployment={deployment}
/>
</Grid>
</Grid>
))}
</Grid>
);
};
@@ -1,16 +0,0 @@
/*
* Copyright 2020 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 { DeploymentsAccordions } from './DeploymentsAccordions';
@@ -1,113 +0,0 @@
/*
* Copyright 2020 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 { screen } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import { ErrorPanel } from './ErrorPanel';
describe('ErrorPanel', () => {
it('displays path and status code when a cluster has an HTTP error', async () => {
await renderInTestApp(
<ErrorPanel
entityName="THIS_ENTITY"
clustersWithErrors={[
{
cluster: { name: 'THIS_CLUSTER' },
resources: [],
podMetrics: [],
errors: [
{
errorType: 'SYSTEM_ERROR',
statusCode: 500,
resourcePath: 'some/resource',
},
],
},
]}
/>,
);
// title
expect(
screen.getByText(
'There was a problem retrieving some Kubernetes resources for the entity: THIS_ENTITY. This could mean that the Error Reporting card is not completely accurate.',
),
).toBeInTheDocument();
// message
expect(screen.getByText('Errors:')).toBeInTheDocument();
expect(screen.getByText('Cluster: THIS_CLUSTER')).toBeInTheDocument();
expect(
screen.getByText(
"Error fetching Kubernetes resource: 'some/resource', error: SYSTEM_ERROR, status code: 500",
),
).toBeInTheDocument();
});
it('displays message for non-HTTP-status-related fetch errors', async () => {
await renderInTestApp(
<ErrorPanel
entityName="THIS_ENTITY"
clustersWithErrors={[
{
cluster: { name: 'THIS_CLUSTER' },
resources: [],
podMetrics: [],
errors: [
{
errorType: 'FETCH_ERROR',
message: 'description of error',
},
],
},
]}
/>,
);
// title
expect(
screen.getByText(
'There was a problem retrieving some Kubernetes resources for the entity: THIS_ENTITY. This could mean that the Error Reporting card is not completely accurate.',
),
).toBeInTheDocument();
// message
expect(screen.getByText('Errors:')).toBeInTheDocument();
expect(screen.getByText('Cluster: THIS_CLUSTER')).toBeInTheDocument();
expect(
screen.getByText(
'Error communicating with Kubernetes: FETCH_ERROR, message: description of error',
),
).toBeInTheDocument();
});
it('displays error message', async () => {
await renderInTestApp(
<ErrorPanel entityName="THIS_ENTITY" errorMessage="SOME_ERROR_MESSAGE" />,
);
// title
expect(
screen.getByText(
'There was a problem retrieving some Kubernetes resources for the entity: THIS_ENTITY. This could mean that the Error Reporting card is not completely accurate.',
),
).toBeInTheDocument();
// message
expect(screen.getByText('Errors: SOME_ERROR_MESSAGE')).toBeInTheDocument();
});
});
@@ -1,67 +0,0 @@
/*
* Copyright 2020 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 { Typography } from '@material-ui/core';
import { ClusterObjects } from '@backstage/plugin-kubernetes-common';
import { WarningPanel } from '@backstage/core-components';
const clustersWithErrorsToErrorMessage = (
clustersWithErrors: ClusterObjects[],
): React.ReactNode => {
return clustersWithErrors.map((c, i) => {
return (
<div key={i}>
<Typography variant="body2">{`Cluster: ${c.cluster.name}`}</Typography>
{c.errors.map((e, j) => {
return (
<Typography variant="body2" key={j}>
{e.errorType === 'FETCH_ERROR'
? `Error communicating with Kubernetes: ${e.errorType}, message: ${e.message}`
: `Error fetching Kubernetes resource: '${e.resourcePath}', error: ${e.errorType}, status code: ${e.statusCode}`}
</Typography>
);
})}
<br />
</div>
);
});
};
type ErrorPanelProps = {
entityName: string;
errorMessage?: string;
clustersWithErrors?: ClusterObjects[];
children?: React.ReactNode;
};
export const ErrorPanel = ({
entityName,
errorMessage,
clustersWithErrors,
}: ErrorPanelProps) => (
<WarningPanel
title="There was a problem retrieving Kubernetes objects"
message={`There was a problem retrieving some Kubernetes resources for the entity: ${entityName}. This could mean that the Error Reporting card is not completely accurate.`}
>
{clustersWithErrors && (
<div>Errors: {clustersWithErrorsToErrorMessage(clustersWithErrors)}</div>
)}
{errorMessage && (
<Typography variant="body2">Errors: {errorMessage}</Typography>
)}
</WarningPanel>
);
@@ -1,16 +0,0 @@
/*
* Copyright 2020 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 { ErrorPanel } from './ErrorPanel';
@@ -1,90 +0,0 @@
/*
* Copyright 2020 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 * as React from 'react';
import { DetectedError, DetectedErrorsByCluster } from '../../error-detection';
import { Table, TableColumn } from '@backstage/core-components';
type ErrorReportingProps = {
detectedErrors: DetectedErrorsByCluster;
};
const columns: TableColumn<Row>[] = [
{
title: 'cluster',
width: '10%',
render: (row: Row) => row.clusterName,
},
{
title: 'namespace',
width: '10%',
render: (row: Row) => row.error.sourceRef.namespace,
},
{
title: 'kind',
width: '10%',
render: (row: Row) => row.error.sourceRef.kind,
},
{
title: 'name',
width: '30%',
render: (row: Row) => {
return <>{row.error.sourceRef.name} </>;
},
},
{
title: 'messages',
width: '40%',
render: (row: Row) => row.error.message,
},
];
interface Row {
clusterName: string;
error: DetectedError;
}
const sortBySeverity = (a: Row, b: Row) => {
if (a.error.severity < b.error.severity) {
return 1;
} else if (b.error.severity < a.error.severity) {
return -1;
}
return 0;
};
export const ErrorReporting = ({ detectedErrors }: ErrorReportingProps) => {
const errors = Array.from(detectedErrors.entries())
.flatMap(([clusterName, resourceErrors]) => {
return resourceErrors.map(e => ({
clusterName,
error: e,
}));
})
.sort(sortBySeverity);
return (
<>
{errors.length !== 0 && (
<Table
title="Error Reporting"
data={errors}
columns={columns}
options={{ paging: true, search: false, emptyRowsWhenPaging: false }}
/>
)}
</>
);
};
@@ -1,16 +0,0 @@
/*
* Copyright 2020 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 { ErrorReporting } from './ErrorReporting';
@@ -1,52 +0,0 @@
/*
* Copyright 2020 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 { screen } from '@testing-library/react';
import * as hpas from './__fixtures__/horizontalpodautoscalers.json';
import { renderInTestApp } from '@backstage/test-utils';
import { HorizontalPodAutoscalerDrawer } from './HorizontalPodAutoscalerDrawer';
describe('HorizontalPodAutoscalersDrawer', () => {
it('should render hpa drawer', async () => {
await renderInTestApp(
<HorizontalPodAutoscalerDrawer hpa={hpas[0] as any} expanded>
<h1>CHILD</h1>
</HorizontalPodAutoscalerDrawer>,
);
expect(screen.getByText('dice-roller')).toBeInTheDocument();
expect(screen.getByText('CHILD')).toBeInTheDocument();
expect(screen.getByText('HorizontalPodAutoscaler')).toBeInTheDocument();
expect(screen.getByText('YAML')).toBeInTheDocument();
expect(
screen.getByText('Target CPU Utilization Percentage'),
).toBeInTheDocument();
expect(screen.getByText('50')).toBeInTheDocument();
expect(
screen.getByText('Current CPU Utilization Percentage'),
).toBeInTheDocument();
expect(screen.getByText('30')).toBeInTheDocument();
expect(screen.getByText('Min Replicas')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('Max Replicas')).toBeInTheDocument();
expect(screen.getByText('15')).toBeInTheDocument();
expect(screen.getByText('Current Replicas')).toBeInTheDocument();
expect(screen.getByText('13')).toBeInTheDocument();
expect(screen.getByText('Desired Replicas')).toBeInTheDocument();
expect(screen.getByText('14')).toBeInTheDocument();
});
});
@@ -1,50 +0,0 @@
/*
* Copyright 2020 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 { V1HorizontalPodAutoscaler } from '@kubernetes/client-node';
import { KubernetesStructuredMetadataTableDrawer } from '../KubernetesDrawer';
/** @public */
export const HorizontalPodAutoscalerDrawer = (props: {
hpa: V1HorizontalPodAutoscaler;
expanded?: boolean;
children?: React.ReactNode;
}) => {
const { hpa, expanded, children } = props;
return (
<KubernetesStructuredMetadataTableDrawer
kind="HorizontalPodAutoscaler"
object={hpa}
expanded={expanded}
renderObject={(hpaObject: V1HorizontalPodAutoscaler) => {
return {
targetCPUUtilizationPercentage:
hpaObject.spec?.targetCPUUtilizationPercentage,
currentCPUUtilizationPercentage:
hpaObject.status?.currentCPUUtilizationPercentage,
minReplicas: hpaObject.spec?.minReplicas,
maxReplicas: hpaObject.spec?.maxReplicas,
currentReplicas: hpaObject.status?.currentReplicas,
desiredReplicas: hpaObject.status?.desiredReplicas,
};
}}
>
{children}
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -1,82 +0,0 @@
[
{
"metadata": {
"annotations": {
"autoscaling.alpha.kubernetes.io/conditions": "[{\"type\":\"AbleToScale\",\"status\":\"True\",\"lastTransitionTime\":\"2020-09-28T13:28:15Z\",\"reason\":\"SucceededGetScale\",\"message\":\"the HPA controller was able to get the target's current scale\"},{\"type\":\"ScalingActive\",\"status\":\"False\",\"lastTransitionTime\":\"2020-09-28T13:28:15Z\",\"reason\":\"FailedGetResourceMetric\",\"message\":\"the HPA was unable to compute the replica count: unable to get metrics for resource cpu: unable to fetch metrics from resource metrics API: the server could not find the requested resource (get pods.metrics.k8s.io)\"}]",
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"autoscaling/v1\",\"kind\":\"HorizontalPodAutoscaler\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"maxReplicas\":15,\"minReplicas\":10,\"scaleTargetRef\":{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"name\":\"dice-roller\"},\"targetCPUUtilizationPercentage\":50}}\n"
},
"creationTimestamp": "2020-09-28T13:28:00.000Z",
"labels": {
"backstage.io/kubernetes-id": "dice-roller"
},
"managedFields": [
{
"apiVersion": "autoscaling/v1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:metadata": {
"f:annotations": {
"f:autoscaling.alpha.kubernetes.io/conditions": {}
}
},
"f:status": {
"f:currentReplicas": {}
}
},
"manager": "kube-controller-manager",
"operation": "Update",
"time": "2020-09-28T13:28:15.000Z"
},
{
"apiVersion": "autoscaling/v1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:metadata": {
"f:annotations": {
".": {},
"f:kubectl.kubernetes.io/last-applied-configuration": {}
},
"f:labels": {
".": {},
"f:backstage.io/kubernetes-id": {}
}
},
"f:spec": {
"f:maxReplicas": {},
"f:minReplicas": {},
"f:scaleTargetRef": {
"f:apiVersion": {},
"f:kind": {},
"f:name": {}
},
"f:targetCPUUtilizationPercentage": {}
}
},
"manager": "kubectl",
"operation": "Update",
"time": "2020-09-28T13:28:21.000Z"
}
],
"name": "dice-roller",
"namespace": "default",
"resourceVersion": "698957",
"selfLink": "/apis/autoscaling/v1/namespaces/default/horizontalpodautoscalers/dice-roller",
"uid": "a70c8a90-5605-4d7d-adea-05cfb8d9d446"
},
"spec": {
"maxReplicas": 15,
"minReplicas": 10,
"scaleTargetRef": {
"apiVersion": "apps/v1",
"kind": "Deployment",
"name": "dice-roller"
},
"targetCPUUtilizationPercentage": 50
},
"status": {
"currentReplicas": 13,
"desiredReplicas": 14,
"currentCPUUtilizationPercentage": 30
}
}
]
@@ -1,17 +0,0 @@
/*
* Copyright 2020 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 { HorizontalPodAutoscalerDrawer } from './HorizontalPodAutoscalerDrawer';
@@ -1,42 +0,0 @@
/*
* Copyright 2020 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 { renderInTestApp, textContentMatcher } from '@backstage/test-utils';
import { screen } from '@testing-library/react';
import React from 'react';
import { IngressDrawer } from './IngressDrawer';
import * as ingresses from './__fixtures__/2-ingresses.json';
describe('IngressDrawer', () => {
it('should render ingress drawer', async () => {
await renderInTestApp(
<IngressDrawer ingress={(ingresses as any).ingresses[0]} expanded />,
);
expect(screen.getAllByText('awesome-service')).toHaveLength(4);
expect(screen.getByText('YAML')).toBeInTheDocument();
expect(screen.getByText('Rules')).toBeInTheDocument();
expect(
screen.getByText(textContentMatcher('Host: api.awesome-host.io')),
).toBeInTheDocument();
expect(
screen.getAllByText(textContentMatcher('Service Port: 80')),
).toHaveLength(2);
expect(
screen.getAllByText(textContentMatcher('Service Name: awesome-service')),
).toHaveLength(2);
});
});
@@ -1,58 +0,0 @@
/*
* Copyright 2020 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 { V1Ingress } from '@kubernetes/client-node';
import { KubernetesStructuredMetadataTableDrawer } from '../KubernetesDrawer';
import { Typography, Grid } from '@material-ui/core';
export const IngressDrawer = ({
ingress,
expanded,
}: {
ingress: V1Ingress;
expanded?: boolean;
}) => {
return (
<KubernetesStructuredMetadataTableDrawer
object={ingress}
expanded={expanded}
kind="Ingress"
renderObject={(ingressObject: V1Ingress) => {
return ingressObject.spec || {};
}}
>
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
spacing={0}
>
<Grid item>
<Typography variant="body1">
{ingress.metadata?.name ?? 'unknown object'}
</Typography>
</Grid>
<Grid item>
<Typography color="textSecondary" variant="subtitle1">
Ingress
</Typography>
</Grid>
</Grid>
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -1,33 +0,0 @@
/*
* Copyright 2020 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 { screen } from '@testing-library/react';
import * as oneIngressFixture from './__fixtures__/2-ingresses.json';
import { renderInTestApp } from '@backstage/test-utils';
import { IngressesAccordions } from './IngressesAccordions';
import { kubernetesProviders } from '../../hooks/test-utils';
describe('IngressesAccordions', () => {
it('should render 1 ingress', async () => {
const wrapper = kubernetesProviders(oneIngressFixture, new Set());
await renderInTestApp(wrapper(<IngressesAccordions />));
expect(screen.getByText('awesome-service')).toBeInTheDocument();
expect(screen.getByText('Ingress')).toBeInTheDocument();
});
});
@@ -1,97 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useContext } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Grid,
} from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { V1Ingress } from '@kubernetes/client-node';
import { IngressDrawer } from './IngressDrawer';
import { GroupedResponsesContext } from '../../hooks';
import { StructuredMetadataTable } from '@backstage/core-components';
type IngressesAccordionsProps = {};
type IngressAccordionProps = {
ingress: V1Ingress;
};
type IngressSummaryProps = {
ingress: V1Ingress;
};
const IngressSummary = ({ ingress }: IngressSummaryProps) => {
return (
<Grid
container
direction="row"
justifyContent="flex-start"
alignItems="center"
>
<Grid xs={12} item>
<IngressDrawer ingress={ingress} />
</Grid>
</Grid>
);
};
type IngressCardProps = {
ingress: V1Ingress;
};
const IngressCard = ({ ingress }: IngressCardProps) => {
return (
<StructuredMetadataTable
metadata={{
...ingress.spec,
}}
/>
);
};
const IngressAccordion = ({ ingress }: IngressAccordionProps) => {
return (
<Accordion TransitionProps={{ unmountOnExit: true }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<IngressSummary ingress={ingress} />
</AccordionSummary>
<AccordionDetails>
<IngressCard ingress={ingress} />
</AccordionDetails>
</Accordion>
);
};
export const IngressesAccordions = ({}: IngressesAccordionsProps) => {
const groupedResponses = useContext(GroupedResponsesContext);
return (
<Grid
container
direction="row"
justifyContent="flex-start"
alignItems="flex-start"
>
{groupedResponses.ingresses.map((ingress, i) => (
<Grid item key={i} xs>
<IngressAccordion ingress={ingress} />
</Grid>
))}
</Grid>
);
};
@@ -1,57 +0,0 @@
{
"ingresses": [
{
"metadata": {
"annotations": {
"artifact.spinnaker.io/location": "default",
"artifact.spinnaker.io/name": "awesome-service",
"artifact.spinnaker.io/type": "kubernetes/ingress",
"kubernetes.io/ingress.class": "traefik",
"kubernetes.io/ingress.global-static-ip-name": "traefik-tcp-lb",
"moniker.spinnaker.io/application": "awesome-service",
"moniker.spinnaker.io/cluster": "ingress awesome-service"
},
"creationTimestamp": "2018-11-16T14:00:13.000Z",
"generation": 11,
"labels": {
"app": "awesome-service",
"app.kubernetes.io/managed-by": "spinnaker",
"app.kubernetes.io/name": "awesome-service"
},
"name": "awesome-service",
"namespace": "default",
"resourceVersion": "564824116",
"selfLink": "/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/awesome-service",
"uid": "f072e0b4-e9a7-11e8-af65-42010a9c0022"
},
"spec": {
"rules": [
{
"host": "api.awesome-host.io",
"http": {
"paths": [
{
"backend": {
"serviceName": "awesome-service",
"servicePort": 80
},
"path": "/v1/awesome-service"
},
{
"backend": {
"serviceName": "awesome-service",
"servicePort": 80
},
"path": "/v1/awesome-services"
}
]
}
}
]
},
"status": {
"loadBalancer": {}
}
}
]
}
@@ -1,16 +0,0 @@
/*
* Copyright 2020 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 { IngressesAccordions } from './IngressesAccordions';
@@ -1,45 +0,0 @@
/*
* Copyright 2021 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 { screen } from '@testing-library/react';
import { JobsAccordions } from './JobsAccordions';
import * as oneCronJobsFixture from '../../__fixtures__/1-cronjobs.json';
import { renderInTestApp } from '@backstage/test-utils';
import { kubernetesProviders } from '../../hooks/test-utils';
import { V1Job, ObjectSerializer } from '@kubernetes/client-node';
describe('JobsAccordions', () => {
it('should render 2 jobs', async () => {
const wrapper = kubernetesProviders(oneCronJobsFixture, new Set<string>());
const jobs: V1Job[] = oneCronJobsFixture.jobs.map(
job => ObjectSerializer.deserialize(job, 'V1Job') as V1Job,
);
await renderInTestApp(wrapper(<JobsAccordions jobs={jobs} />));
expect(
screen.getByText('dice-roller-cronjob-1637028600'),
).toBeInTheDocument();
expect(screen.getByText('Running')).toBeInTheDocument();
expect(
screen.getByText('dice-roller-cronjob-1637025000'),
).toBeInTheDocument();
expect(screen.getByText('Succeeded')).toBeInTheDocument();
});
});
@@ -1,123 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useContext } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Grid,
} from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { V1Job, V1Pod } from '@kubernetes/client-node';
import { PodsTable } from '../Pods';
import { JobDrawer } from './JobsDrawer';
import { getOwnedResources } from '../../utils/owner';
import { GroupedResponsesContext } from '../../hooks';
import {
StatusError,
StatusOK,
StatusPending,
} from '@backstage/core-components';
type JobsAccordionsProps = {
jobs: V1Job[];
children?: React.ReactNode;
};
type JobAccordionProps = {
job: V1Job;
ownedPods: V1Pod[];
children?: React.ReactNode;
};
type JobSummaryProps = {
job: V1Job;
children?: React.ReactNode;
};
const JobSummary = ({ job }: JobSummaryProps) => {
return (
<Grid
container
direction="row"
justifyContent="space-between"
alignItems="center"
spacing={0}
>
<Grid xs={6} item>
<JobDrawer job={job} />
</Grid>
<Grid
item
container
xs={6}
direction="column"
justifyContent="flex-start"
alignItems="flex-end"
spacing={0}
>
<Grid item>
{job.status?.succeeded && <StatusOK>Succeeded</StatusOK>}
{job.status?.active && <StatusPending>Running</StatusPending>}
{job.status?.failed && <StatusError>Failed</StatusError>}
</Grid>
<Grid item>Start time: {job.status?.startTime?.toString()}</Grid>
{job.status?.completionTime && (
<Grid item>
Completion time: {job.status.completionTime.toString()}
</Grid>
)}
</Grid>
</Grid>
);
};
const JobAccordion = ({ job, ownedPods }: JobAccordionProps) => {
return (
<Accordion TransitionProps={{ unmountOnExit: true }} variant="outlined">
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<JobSummary job={job} />
</AccordionSummary>
<AccordionDetails>
<PodsTable pods={ownedPods} />
</AccordionDetails>
</Accordion>
);
};
export const JobsAccordions = ({ jobs }: JobsAccordionsProps) => {
const groupedResponses = useContext(GroupedResponsesContext);
return (
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
>
{jobs.map((job, i) => (
<Grid container item key={i} xs>
<Grid item xs>
<JobAccordion
ownedPods={getOwnedResources(job, groupedResponses.pods)}
job={job}
/>
</Grid>
</Grid>
))}
</Grid>
);
};
@@ -1,35 +0,0 @@
/*
* Copyright 2021 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 * as oneCronJobsFixture from '../../__fixtures__/1-cronjobs.json';
import { renderInTestApp } from '@backstage/test-utils';
import { JobDrawer } from './JobsDrawer';
describe('JobDrawer', () => {
it('should render job drawer', async () => {
const { getByText, getAllByText } = await renderInTestApp(
<JobDrawer job={(oneCronJobsFixture as any).jobs[0]} expanded />,
);
expect(getAllByText('dice-roller-cronjob-1637025000')).toHaveLength(2);
expect(getAllByText('Job')).toHaveLength(2);
expect(getByText('YAML')).toBeInTheDocument();
expect(getByText('Parallelism')).toBeInTheDocument();
expect(getByText('Completions')).toBeInTheDocument();
expect(getByText('Backoff Limit')).toBeInTheDocument();
expect(getByText('Start Time')).toBeInTheDocument();
});
});
@@ -1,62 +0,0 @@
/*
* Copyright 2021 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 { V1Job } from '@kubernetes/client-node';
import { KubernetesStructuredMetadataTableDrawer } from '../KubernetesDrawer';
import { Typography, Grid } from '@material-ui/core';
export const JobDrawer = ({
job,
expanded,
}: {
job: V1Job;
expanded?: boolean;
}) => {
return (
<KubernetesStructuredMetadataTableDrawer
object={job}
expanded={expanded}
kind="Job"
renderObject={(jobObj: V1Job) => {
return {
parallelism: jobObj.spec?.parallelism ?? '???',
completions: jobObj.spec?.completions ?? '???',
backoffLimit: jobObj.spec?.backoffLimit ?? '???',
startTime: jobObj.status?.startTime ?? '???',
};
}}
>
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
spacing={0}
>
<Grid item>
<Typography variant="body1">
{job.metadata?.name ?? 'unknown object'}
</Typography>
</Grid>
<Grid item>
<Typography color="textSecondary" variant="subtitle1">
Job
</Typography>
</Grid>
</Grid>
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -1,16 +0,0 @@
/*
* Copyright 2021 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 { JobsAccordions } from './JobsAccordions';
@@ -1,183 +0,0 @@
/*
* 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, { ChangeEvent, useState } from 'react';
import { IObjectMeta } from '@kubernetes-models/apimachinery/apis/meta/v1/ObjectMeta';
import {
createStyles,
Drawer,
makeStyles,
Theme,
Grid,
IconButton,
Switch,
Typography,
Button,
withStyles,
FormControlLabel,
} from '@material-ui/core';
import CloseIcon from '@material-ui/icons/Close';
import { ManifestYaml } from './ManifestYaml';
const useDrawerContentStyles = makeStyles((_theme: Theme) =>
createStyles({
header: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
},
content: {
height: '80%',
},
icon: {
fontSize: 20,
},
}),
);
interface KubernetesObject {
kind: string;
metadata?: IObjectMeta;
}
interface KubernetesDrawerContentProps {
close: () => void;
kubernetesObject: KubernetesObject;
header?: React.ReactNode;
children?: React.ReactNode;
}
export const KubernetesDrawerContent = ({
children,
header,
kubernetesObject,
close,
}: KubernetesDrawerContentProps) => {
const classes = useDrawerContentStyles();
const [isYaml, setIsYaml] = useState<boolean>(false);
return (
<>
<div className={classes.header}>
<Grid container justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={11}>
<Typography variant="h5">
{kubernetesObject.metadata?.name}
</Typography>
</Grid>
<Grid item xs={1}>
<IconButton
key="dismiss"
title="Close the drawer"
onClick={() => close()}
color="inherit"
>
<CloseIcon className={classes.icon} />
</IconButton>
</Grid>
<Grid item xs={12}>
{header}
</Grid>
<Grid item xs={12}>
<FormControlLabel
control={
<Switch
checked={isYaml}
onChange={event => {
setIsYaml(event.target.checked);
}}
name="YAML"
/>
}
label="YAML"
/>
</Grid>
</Grid>
</div>
<div className={classes.content}>
{isYaml && <ManifestYaml object={kubernetesObject} />}
{!isYaml && children}
</div>
</>
);
};
interface KubernetesDrawerProps {
open?: boolean;
kubernetesObject: KubernetesObject;
label: React.ReactNode;
drawerContentsHeader?: React.ReactNode;
children?: React.ReactNode;
}
const useDrawerStyles = makeStyles((theme: Theme) =>
createStyles({
paper: {
width: '50%',
justifyContent: 'space-between',
padding: theme.spacing(2.5),
},
}),
);
const DrawerButton = withStyles({
root: {
padding: '6px 5px',
},
label: {
textTransform: 'none',
},
})(Button);
export const KubernetesDrawer = ({
open,
label,
drawerContentsHeader,
kubernetesObject,
children,
}: KubernetesDrawerProps) => {
const classes = useDrawerStyles();
const [isOpen, setIsOpen] = useState<boolean>(open ?? false);
const toggleDrawer = (e: ChangeEvent<{}>, newValue: boolean) => {
e.stopPropagation();
setIsOpen(newValue);
};
return (
<>
<DrawerButton onClick={() => setIsOpen(true)}>{label}</DrawerButton>
<Drawer
classes={{
paper: classes.paper,
}}
anchor="right"
open={isOpen}
onClose={(e: any) => toggleDrawer(e, false)}
onClick={event => event.stopPropagation()}
>
{isOpen && (
<KubernetesDrawerContent
header={drawerContentsHeader}
kubernetesObject={kubernetesObject}
children={children}
close={() => setIsOpen(false)}
/>
)}
</Drawer>
</>
);
};
@@ -1,298 +0,0 @@
/*
* Copyright 2020 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, { ChangeEvent, useContext, useState } from 'react';
import {
Button,
Typography,
makeStyles,
IconButton,
createStyles,
Theme,
Drawer,
Switch,
FormControlLabel,
Grid,
} from '@material-ui/core';
import Close from '@material-ui/icons/Close';
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import { V1ObjectMeta } from '@kubernetes/client-node';
import { withStyles } from '@material-ui/core/styles';
import {
LinkButton as BackstageButton,
StructuredMetadataTable,
WarningPanel,
} from '@backstage/core-components';
import { ClusterContext } from '../../hooks';
import { formatClusterLink } from '../../utils/clusterLinks';
import { ClusterAttributes } from '@backstage/plugin-kubernetes-common';
import { FormatClusterLinkOptions } from '../../utils/clusterLinks/formatClusterLink';
import { ManifestYaml } from './ManifestYaml';
const useDrawerStyles = makeStyles((theme: Theme) =>
createStyles({
paper: {
width: '50%',
justifyContent: 'space-between',
padding: theme.spacing(2.5),
},
}),
);
const useDrawerContentStyles = makeStyles((_: Theme) =>
createStyles({
header: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
},
errorMessage: {
marginTop: '1em',
marginBottom: '1em',
},
options: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
},
icon: {
fontSize: 20,
},
content: {
height: '80%',
},
}),
);
const PodDrawerButton = withStyles({
root: {
padding: '6px 5px',
},
label: {
textTransform: 'none',
},
})(Button);
type ErrorPanelProps = {
cluster: ClusterAttributes;
errorMessage?: string;
children?: React.ReactNode;
};
export const LinkErrorPanel = ({ cluster, errorMessage }: ErrorPanelProps) => (
<WarningPanel
title="There was a problem formatting the link to the Kubernetes dashboard"
message={`Could not format the link to the dashboard of your cluster named '${
cluster.name
}'. Its dashboardApp property has been set to '${
cluster.dashboardApp || 'standard'
}.'`}
>
{errorMessage && (
<Typography variant="body2">Errors: {errorMessage}</Typography>
)}
</WarningPanel>
);
interface KubernetesDrawerable {
metadata?: V1ObjectMeta;
}
interface KubernetesStructuredMetadataTableDrawerContentProps<
T extends KubernetesDrawerable,
> {
toggleDrawer: (e: ChangeEvent<{}>, isOpen: boolean) => void;
object: T;
renderObject: (obj: T) => object;
kind: string;
}
function replaceNullsWithUndefined(someObj: any) {
const replacer = (_: any, value: any) =>
String(value) === 'null' || String(value) === 'undefined'
? undefined
: value;
return JSON.parse(JSON.stringify(someObj, replacer));
}
function tryFormatClusterLink(options: FormatClusterLinkOptions) {
try {
return {
clusterLink: formatClusterLink(options),
errorMessage: '',
};
} catch (err) {
return {
clusterLink: '',
errorMessage: err.message || err.toString(),
};
}
}
const KubernetesStructuredMetadataTableDrawerContent = <
T extends KubernetesDrawerable,
>({
toggleDrawer,
object,
renderObject,
kind,
}: KubernetesStructuredMetadataTableDrawerContentProps<T>) => {
const [isYaml, setIsYaml] = useState<boolean>(false);
const classes = useDrawerContentStyles();
const cluster = useContext(ClusterContext);
const { clusterLink, errorMessage } = tryFormatClusterLink({
dashboardUrl: cluster.dashboardUrl,
dashboardApp: cluster.dashboardApp,
dashboardParameters: cluster.dashboardParameters,
object,
kind,
});
return (
<>
<div className={classes.header}>
<Grid container justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={11}>
<Typography variant="h5">
{object.metadata?.name ?? 'unknown name'}
</Typography>
</Grid>
<Grid item xs={1}>
<IconButton
key="dismiss"
title="Close the drawer"
onClick={e => toggleDrawer(e, false)}
color="inherit"
>
<Close className={classes.icon} />
</IconButton>
</Grid>
<Grid item xs={11}>
<Typography color="textSecondary" variant="body1">
{kind}
</Typography>
</Grid>
<Grid item xs={11}>
<FormControlLabel
control={
<Switch
checked={isYaml}
onChange={event => {
setIsYaml(event.target.checked);
}}
name="YAML"
/>
}
label="YAML"
/>
</Grid>
</Grid>
</div>
{errorMessage && (
<div className={classes.errorMessage}>
<LinkErrorPanel cluster={cluster} errorMessage={errorMessage} />
</div>
)}
<div className={classes.options}>
<div>
{clusterLink && (
<BackstageButton
variant="outlined"
color="primary"
size="small"
to={clusterLink}
endIcon={<OpenInNewIcon />}
>
Open Kubernetes Dashboard
</BackstageButton>
)}
</div>
</div>
<div className={classes.content}>
{isYaml && <ManifestYaml object={object} />}
{!isYaml && (
<StructuredMetadataTable
metadata={renderObject(replaceNullsWithUndefined(object))}
/>
)}
</div>
</>
);
};
interface KubernetesStructuredMetadataTableDrawerProps<
T extends KubernetesDrawerable,
> {
object: T;
renderObject: (obj: T) => object;
buttonVariant?: 'h5' | 'subtitle2';
kind: string;
expanded?: boolean;
children?: React.ReactNode;
}
export const KubernetesStructuredMetadataTableDrawer = <
T extends KubernetesDrawerable,
>({
object,
renderObject,
kind,
buttonVariant = 'subtitle2',
expanded = false,
children,
}: KubernetesStructuredMetadataTableDrawerProps<T>) => {
const [isOpen, setIsOpen] = useState(expanded);
const classes = useDrawerStyles();
const toggleDrawer = (e: ChangeEvent<{}>, newValue: boolean) => {
e.stopPropagation();
setIsOpen(newValue);
};
return (
<>
<PodDrawerButton
onClick={e => toggleDrawer(e, true)}
onFocus={event => event.stopPropagation()}
>
{children === undefined ? (
<Typography variant={buttonVariant}>
{object.metadata?.name ?? 'unknown object'}
</Typography>
) : (
children
)}
</PodDrawerButton>
<Drawer
classes={{
paper: classes.paper,
}}
anchor="right"
open={isOpen}
onClose={(e: any) => toggleDrawer(e, false)}
onClick={event => event.stopPropagation()}
>
<KubernetesStructuredMetadataTableDrawerContent
kind={kind}
toggleDrawer={toggleDrawer}
object={object}
renderObject={renderObject}
/>
</Drawer>
</>
);
};
@@ -1,60 +0,0 @@
/*
* 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 { CodeSnippet } from '@backstage/core-components';
import { FormControlLabel, Switch } from '@material-ui/core';
import jsyaml from 'js-yaml';
import React, { useState } from 'react';
export interface ManifestYamlProps {
object: object;
}
export const ManifestYaml = ({ object }: ManifestYamlProps) => {
// Toggle whether the Kubernetes resource managed fields should be shown in
// the YAML display. This toggle is only available when the YAML is being
// shown because managed fields are never visible in the structured display.
const [managedFields, setManagedFields] = useState<boolean>(false);
return (
<>
<FormControlLabel
control={
<Switch
checked={managedFields}
onChange={event => {
setManagedFields(event.target.checked);
}}
name="Managed Fields"
/>
}
label="Managed Fields"
/>
<CodeSnippet
language="yaml"
text={jsyaml.dump(object, {
// NOTE: this will remove any field called `managedFields`
// not just the metadata one
// TODO: @mclarke make this only remove the `metadata.managedFields`
replacer: (key: string, value: string): any => {
if (!managedFields) {
return key === 'managedFields' ? undefined : value;
}
return value;
},
})}
/>
</>
);
};
@@ -1,18 +0,0 @@
/*
* Copyright 2022 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 './KubernetesStructuredMetadataTableDrawer';
export * from './KubernetesDrawer';
@@ -1,97 +0,0 @@
/*
* 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 { DiscoveryApi, discoveryApiRef } from '@backstage/core-plugin-api';
import {
renderInTestApp,
TestApiProvider,
textContentMatcher,
} from '@backstage/test-utils';
import '@testing-library/jest-dom';
import { screen } from '@testing-library/react';
import WS from 'jest-websocket-mock';
import React from 'react';
import './matchMedia.mock';
import { PodExecTerminal } from './PodExecTerminal';
global.TextEncoder = require('util').TextEncoder;
const textEncoder = new TextEncoder();
describe('PodExecTerminal', () => {
const clusterName = 'cluster1';
const containerName = 'container2';
const podName = 'pod1';
const podNamespace = 'podNamespace';
const mockDiscoveryApi: Partial<DiscoveryApi> = {
getBaseUrl: () => Promise.resolve('http://localhost'),
};
it('Should render an XTerm web terminal', async () => {
await renderInTestApp(
<TestApiProvider apis={[[discoveryApiRef, mockDiscoveryApi]]}>
<PodExecTerminal
clusterName={clusterName}
containerName={containerName}
podName={podName}
podNamespace={podNamespace}
/>
</TestApiProvider>,
);
await expect(
screen.findByText(
textContentMatcher('Starting terminal, please wait...'),
),
).resolves.toBeInTheDocument();
});
it('Should connect to WebSocket server & render response', async () => {
const server = new WS(
'ws://localhost/proxy/api/v1/namespaces/podNamespace/pods/pod1/exec?container=container2&stdin=true&stdout=true&stderr=true&tty=true&command=%2Fbin%2Fsh',
);
await renderInTestApp(
<TestApiProvider apis={[[discoveryApiRef, mockDiscoveryApi]]}>
<PodExecTerminal
clusterName={clusterName}
containerName={containerName}
podName={podName}
podNamespace={podNamespace}
/>
</TestApiProvider>,
);
// xterm uses a "W" character as a "measure element" -- if it exists, the terminal rendered correctly
await expect(
screen.findByText(textContentMatcher('W')),
).resolves.toBeInTheDocument();
await server.connected;
const { buffer } = Uint8Array.from([
1,
...textEncoder.encode('hello world'),
]);
server.send(buffer);
await expect(
screen.findByText(textContentMatcher('hello world')),
).resolves.toBeInTheDocument();
});
});
@@ -1,131 +0,0 @@
/*
* 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 'xterm/css/xterm.css';
import { discoveryApiRef, useApi } from '@backstage/core-plugin-api';
import React, { useEffect, useMemo, useState } from 'react';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { PodExecTerminalAttachAddon } from './PodExecTerminalAttachAddon';
/**
* Props drilled down to the PodExecTerminal component
*
* @public
*/
export interface PodExecTerminalProps {
clusterName: string;
containerName: string;
podName: string;
podNamespace: string;
}
const hasSocketProtocol = (url: string | URL) =>
/wss?:\/\//.test(url.toString());
/**
* Executes a `/bin/sh` process in the given pod's container and opens a terminal connected to it
*
* @public
*/
export const PodExecTerminal = (props: PodExecTerminalProps) => {
const { containerName, podNamespace, podName } = props;
const [baseUrl, setBaseUrl] = useState(window.location.host);
const terminalRef = React.useRef(null);
const discoveryApi = useApi(discoveryApiRef);
const namespace = podNamespace ?? 'default';
useEffect(() => {
discoveryApi
.getBaseUrl('kubernetes')
.then(url => url ?? window.location.host)
.then(url => url.replace(/^http(s?):\/\//, 'ws$1://'))
.then(url => setBaseUrl(url));
}, [discoveryApi]);
const urlParams = useMemo(() => {
const params = new URLSearchParams({
container: containerName,
stdin: 'true',
stdout: 'true',
stderr: 'true',
tty: 'true',
command: '/bin/sh',
});
return params;
}, [containerName]);
const socketUrl = useMemo(() => {
if (!hasSocketProtocol(baseUrl)) {
return '';
}
return new URL(
`${baseUrl}/proxy/api/v1/namespaces/${namespace}/pods/${podName}/exec?${urlParams}`,
);
}, [baseUrl, namespace, podName, urlParams]);
useEffect(() => {
if (!hasSocketProtocol(socketUrl)) {
return () => {};
}
const terminal = new Terminal();
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
if (terminalRef.current) {
terminal.open(terminalRef.current);
fitAddon.fit();
}
terminal.writeln('Starting terminal, please wait...');
const socket = new WebSocket(socketUrl, ['channel.k8s.io']);
socket.onopen = () => {
terminal.clear();
const attachAddon = new PodExecTerminalAttachAddon(socket, {
bidirectional: true,
});
terminal.loadAddon(attachAddon);
};
socket.onclose = () => {
terminal.writeln('Socket connection closed');
};
return () => {
terminal?.clear();
socket?.close();
};
}, [baseUrl, socketUrl]);
return (
<div
data-testid="terminal"
ref={terminalRef}
style={{
width: '100%',
height: '100%',
}}
/>
);
};
@@ -1,46 +0,0 @@
/*
* 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 { AttachAddon, IAttachOptions } from 'xterm-addon-attach';
export class PodExecTerminalAttachAddon extends AttachAddon {
#textEncoder = new TextEncoder();
constructor(socket: WebSocket, options?: IAttachOptions) {
super(socket, options);
const thisAddon = this as any;
// These methods are private in the original AttachAddon,
// thus have to override at runtime like this
thisAddon._sendBinary = (data: string) => {
if (!thisAddon._checkOpenSocket()) {
return;
}
const buffer = Uint8Array.from([0, ...this.#textEncoder.encode(data)]);
thisAddon._socket.send(buffer);
};
thisAddon._sendData = (data: string) => {
if (!thisAddon._checkOpenSocket()) {
return;
}
thisAddon._sendBinary(data);
};
}
}
@@ -1,110 +0,0 @@
/*
* 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 {
Button,
createStyles,
Dialog,
DialogContent,
DialogTitle,
IconButton,
makeStyles,
Theme,
} from '@material-ui/core';
import CloseIcon from '@material-ui/icons/Close';
import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser';
import React, { useState } from 'react';
import { useIsPodExecTerminalSupported } from '../../hooks';
import { PodExecTerminal, PodExecTerminalProps } from './PodExecTerminal';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
dialogPaper: { minHeight: 'calc(100% - 64px)' },
dialogContent: { flexBasis: 0 },
closeButton: {
position: 'absolute',
right: theme.spacing(1),
top: theme.spacing(1),
color: theme.palette.grey[500],
},
}),
);
/**
* Opens a terminal connected to the given pod's container in a dialog
*
* @public
*/
export const PodExecTerminalDialog = (props: PodExecTerminalProps) => {
const classes = useStyles();
const { clusterName, containerName, podName } = props;
const [open, setOpen] = useState(false);
const isPodExecTerminalSupported = useIsPodExecTerminalSupported();
const openDialog = () => {
setOpen(true);
};
const closeDialog = () => {
setOpen(false);
};
return (
<>
{!isPodExecTerminalSupported.loading &&
isPodExecTerminalSupported.value && (
<Dialog
maxWidth={false}
fullWidth
open={open}
onClose={closeDialog}
PaperProps={{ className: classes.dialogPaper }}
>
<DialogTitle id="dialog-title">
{podName} - {containerName} terminal shell on cluster{' '}
{clusterName}
<IconButton
aria-label="close"
className={classes.closeButton}
onClick={closeDialog}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent className={classes.dialogContent}>
<PodExecTerminal {...props} />
</DialogContent>
</Dialog>
)}
<Button
variant="outlined"
aria-label="open terminal"
component="label"
disabled={
isPodExecTerminalSupported.loading ||
!isPodExecTerminalSupported.value
}
onClick={openDialog}
startIcon={<OpenInBrowserIcon />}
>
Terminal
</Button>
</>
);
};
@@ -1,17 +0,0 @@
/*
* 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 './PodExecTerminal';
export * from './PodExecTerminalDialog';
@@ -1,31 +0,0 @@
/*
* 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.
*/
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
export {};
@@ -1,64 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { ErrorList } from './ErrorList';
import { Pod } from 'kubernetes-models/v1/Pod';
describe('ErrorList', () => {
it('error highlight should render', () => {
const { getByText } = render(
<ErrorList
podAndErrors={[
{
clusterName: 'some-cluster',
pod: {
metadata: {
name: 'some-pod',
namespace: 'some-namespace',
},
} as Pod,
errors: [
{
type: 'some-error',
severity: 10,
message: 'some error message',
occurrenceCount: 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('some-pod')).toBeInTheDocument();
expect(getByText('some error message')).toBeInTheDocument();
});
});
@@ -1,97 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
List,
ListItem,
ListItemText,
Divider,
createStyles,
makeStyles,
Theme,
Paper,
Grid,
} from '@material-ui/core';
import { PodAndErrors } from '../types';
import { FixDialog } from '../FixDialog/FixDialog';
const useStyles = makeStyles((_theme: Theme) =>
createStyles({
root: {
overflow: 'auto',
},
list: {
width: '100%',
},
}),
);
/**
* 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 (
<Paper className={classes.root}>
<List className={classes.list}>
{podAndErrors
.filter(pae => pae.errors.length > 0)
.flatMap(onlyPodWithErrors => {
return onlyPodWithErrors.errors.map((error, i) => {
return (
<React.Fragment
key={`${
onlyPodWithErrors.pod.metadata?.name ?? 'unknown'
}-eli-${i}`}
>
{i > 0 && <Divider key={`error-divider${i}`} />}
<ListItem>
<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>
);
});
})}
</List>
</Paper>
);
};
@@ -1,16 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './ErrorList';
@@ -1,96 +0,0 @@
/*
* 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();
});
});
-154
View File
@@ -1,154 +0,0 @@
/*
* 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} />
)}
</>
);
};
-17
View File
@@ -1,17 +0,0 @@
/*
* 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';
@@ -1,68 +0,0 @@
/*
* 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);
});
});
@@ -1,49 +0,0 @@
/*
* 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]);
};
@@ -1,159 +0,0 @@
/*
* 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',
occurrenceCount: 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',
occurrenceCount: 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',
occurrenceCount: 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();
});
});
@@ -1,187 +0,0 @@
/*
* 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>
</>
);
};
@@ -1,16 +0,0 @@
/*
* 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';
@@ -1,135 +0,0 @@
/*
* 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 { screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { renderInTestApp } from '@backstage/test-utils';
import { ContainerCard } from './ContainerCard';
import { DateTime } from 'luxon';
jest.mock('../../../hooks/useIsPodExecTerminalSupported');
const now = DateTime.now();
const oneHourAgo = now.minus({ hours: 1 }).toISO();
const twoHoursAgo = now.minus({ hours: 2 }).toISO();
describe('ContainerCard', () => {
it('show healthy when all checks pass', async () => {
await renderInTestApp(
<ContainerCard
{...({
podScope: {
name: 'some-name',
namespace: 'some-namespace',
clusterName: 'some-cluster',
},
containerSpec: {
readinessProbe: {},
},
containerStatus: {
name: 'some-name',
image: 'gcr.io/some-proj/some-image',
started: true,
ready: true,
restartCount: 0,
state: {
running: {
startedAt: oneHourAgo,
},
},
},
} as any)}
/>,
);
expect(screen.getByText('Started: 1 hour ago')).toBeInTheDocument();
expect(screen.getByText('Status: Running')).toBeInTheDocument();
expect(screen.getByText('some-name')).toBeInTheDocument();
expect(screen.getByText('gcr.io/some-proj/some-image')).toBeInTheDocument();
expect(screen.getAllByText('✅')).toHaveLength(5);
expect(screen.queryByText('❌')).toBeNull();
});
it('show unhealthy when all checks fail', async () => {
await renderInTestApp(
<ContainerCard
{...({
podScope: {
podName: 'some-name',
podNamespace: 'some-namespace',
clusterName: 'some-cluster',
},
containerSpec: {},
containerStatus: {
name: 'some-name',
image: 'gcr.io/some-proj/some-image',
started: false,
ready: false,
restartCount: 12,
state: {
waiting: {},
},
},
} as any)}
/>,
);
expect(screen.getByText('some-name')).toBeInTheDocument();
expect(screen.getByText('gcr.io/some-proj/some-image')).toBeInTheDocument();
expect(screen.getAllByText('❌')).toHaveLength(5);
expect(screen.queryByText('✅')).toBeNull();
});
it('show correct checks for completed container', async () => {
await renderInTestApp(
<ContainerCard
{...({
podScope: {
podName: 'some-name',
podNamespace: 'some-namespace',
clusterName: 'some-cluster',
},
containerSpec: {},
containerStatus: {
name: 'some-name',
image: 'gcr.io/some-proj/some-image',
started: false,
ready: false,
restartCount: 0,
state: {
terminated: {
exitCode: 0,
reason: 'Completed',
startedAt: twoHoursAgo,
finishedAt: oneHourAgo,
},
},
},
} as any)}
/>,
);
expect(screen.getByText('some-name')).toBeInTheDocument();
expect(screen.getByText('gcr.io/some-proj/some-image')).toBeInTheDocument();
expect(screen.getByText('Started: 2 hours ago')).toBeInTheDocument();
expect(screen.getByText('Completed: 1 hour ago')).toBeInTheDocument();
expect(
screen.getByText('Execution time: 1 hour, 0 minutes, 0 seconds'),
).toBeInTheDocument();
expect(screen.getByText('Status: Completed')).toBeInTheDocument();
expect(screen.getAllByText('✅')).toHaveLength(2);
expect(screen.queryByText('❌')).toBeNull();
});
});
@@ -1,240 +0,0 @@
/*
* 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 { StructuredMetadataTable } from '@backstage/core-components';
import { ClientContainerStatus } from '@backstage/plugin-kubernetes-common';
import {
Card,
CardActions,
CardContent,
CardHeader,
Grid,
Typography,
} from '@material-ui/core';
import { IContainer, IContainerStatus } from 'kubernetes-models/v1';
import { DateTime } from 'luxon';
import React from 'react';
import { bytesToMiB, formatMillicores } from '../../../utils/resources';
import { PodExecTerminalDialog } from '../../PodExecTerminal/PodExecTerminalDialog';
import { ResourceUtilization } from '../../ResourceUtilization';
import { PodLogsDialog, PodScope } from '../PodLogs';
const getContainerHealthChecks = (
containerSpec: IContainer,
containerStatus: IContainerStatus,
): { [key: string]: boolean } => {
if (containerStatus.state?.terminated?.reason === 'Completed') {
return {
'not waiting to start': containerStatus.state?.waiting === undefined,
'no restarts': containerStatus.restartCount === 0,
};
}
return {
'not waiting to start': containerStatus.state?.waiting === undefined,
started: !!containerStatus.started,
ready: containerStatus.ready,
'no restarts': containerStatus.restartCount === 0,
'readiness probe set':
containerSpec && containerSpec?.readinessProbe !== undefined,
};
};
const getCurrentState = (containerStatus: IContainerStatus): string => {
return (
containerStatus.state?.waiting?.reason ||
containerStatus.state?.terminated?.reason ||
(containerStatus.state?.running !== undefined ? 'Running' : 'Unknown')
);
};
const getStartedAtTime = (
containerStatus: IContainerStatus,
): string | undefined => {
return (
containerStatus.state?.running?.startedAt ||
containerStatus.state?.terminated?.startedAt
);
};
interface ContainerDatetimeProps {
prefix: string;
dateTime: string;
}
const ContainerDatetime = ({ prefix, dateTime }: ContainerDatetimeProps) => {
return (
<Typography variant="subtitle2">
{prefix}:{' '}
{DateTime.fromISO(dateTime).toRelative({
locale: 'en',
})}
</Typography>
);
};
/**
* Props for ContainerCard
*
* @public
*/
export interface ContainerCardProps {
podScope: PodScope;
containerSpec?: IContainer;
containerStatus: IContainerStatus;
containerMetrics?: ClientContainerStatus;
}
/**
* Shows details about a container within a pod
*
* @public
*/
export const ContainerCard: React.FC<ContainerCardProps> = ({
podScope,
containerSpec,
containerStatus,
containerMetrics,
}: ContainerCardProps) => {
// This should never be undefined
if (containerSpec === undefined) {
return <Typography>error reading pod from cluster</Typography>;
}
const containerStartedTime = getStartedAtTime(containerStatus);
const containerFinishedTime = containerStatus.state?.terminated?.finishedAt;
return (
<Card>
<CardHeader
title={containerStatus.name}
subheader={containerStatus.image}
/>
<CardContent>
<Grid container>
<Grid item xs={12}>
{containerStartedTime && (
<ContainerDatetime
prefix="Started"
dateTime={containerStartedTime}
/>
)}
{containerFinishedTime && (
<ContainerDatetime
prefix="Completed"
dateTime={containerFinishedTime}
/>
)}
{containerStartedTime && containerFinishedTime && (
<Typography variant="subtitle2">
Execution time:{' '}
{DateTime.fromISO(containerFinishedTime)
.diff(DateTime.fromISO(containerStartedTime), [
'hours',
'minutes',
'seconds',
])
.toHuman()}
</Typography>
)}
</Grid>
<Grid item xs={12}>
<Typography variant="subtitle2">
Status: {getCurrentState(containerStatus)}
</Typography>
</Grid>
{containerStatus.restartCount > 0 && (
<Grid item xs={12}>
<Typography variant="subtitle2">
Restarts: {containerStatus.restartCount}
</Typography>
</Grid>
)}
<Grid item xs={12}>
<Typography variant="subtitle2">Container health</Typography>
</Grid>
<Grid item xs={12}>
<StructuredMetadataTable
metadata={getContainerHealthChecks(
containerSpec,
containerStatus,
)}
/>
</Grid>
{containerMetrics && (
<Grid container item xs={12} spacing={0}>
<Grid item xs={12}>
<Typography variant="subtitle1">
Resource utilization
</Typography>
</Grid>
<Grid item xs={12} style={{ minHeight: '5rem' }}>
<ResourceUtilization
compressed
title="CPU requests"
usage={containerMetrics.cpuUsage.currentUsage}
total={containerMetrics.cpuUsage.requestTotal}
totalFormatted={formatMillicores(
containerMetrics.cpuUsage.requestTotal,
)}
/>
<ResourceUtilization
compressed
title="CPU limits"
usage={containerMetrics.cpuUsage.currentUsage}
total={containerMetrics.cpuUsage.limitTotal}
totalFormatted={formatMillicores(
containerMetrics.cpuUsage.limitTotal,
)}
/>
<ResourceUtilization
compressed
title="Memory requests"
usage={containerMetrics.memoryUsage.currentUsage}
total={containerMetrics.memoryUsage.requestTotal}
totalFormatted={bytesToMiB(
containerMetrics.memoryUsage.requestTotal,
)}
/>
<ResourceUtilization
compressed
title="Memory limits"
usage={containerMetrics.memoryUsage.currentUsage}
total={containerMetrics.memoryUsage.limitTotal}
totalFormatted={bytesToMiB(
containerMetrics.memoryUsage.limitTotal,
)}
/>
</Grid>
</Grid>
)}
</Grid>
</CardContent>
<CardActions>
<PodLogsDialog
containerScope={{
containerName: containerStatus.name,
...podScope,
}}
/>
<PodExecTerminalDialog
clusterName={podScope.clusterName}
containerName={containerStatus.name}
podName={podScope.podName}
podNamespace={podScope.podNamespace}
/>
</CardActions>
</Card>
);
};
@@ -1,186 +0,0 @@
/*
* 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 { screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { renderInTestApp } from '@backstage/test-utils';
import { PendingPodContent } from './PendingPodContent';
import { IPodCondition } from 'kubernetes-models/v1';
import { DateTime } from 'luxon';
const podWithConditions = (conditions: IPodCondition[]): any => {
return {
metadata: {
name: 'ok-pod',
},
spec: {
containers: [
{
name: 'some-container',
},
],
},
status: {
podIP: '127.0.0.1',
conditions: conditions,
},
};
};
describe('PendingPodContent', () => {
it('show startup conditions - all healthy', async () => {
const oneDayAgo = DateTime.now().minus({ days: 1 }).toISO()!;
await renderInTestApp(
<PendingPodContent
{...{
pod: podWithConditions([
{
type: 'Initialized',
status: 'True',
lastTransitionTime: oneDayAgo,
},
{
type: 'PodScheduled',
status: 'True',
lastTransitionTime: oneDayAgo,
},
{
type: 'ContainersReady',
status: 'True',
lastTransitionTime: oneDayAgo,
},
{
type: 'Ready',
status: 'True',
lastTransitionTime: oneDayAgo,
},
]),
}}
/>,
);
expect(screen.getByText('Pod is Pending. Conditions:')).toBeInTheDocument();
expect(screen.getByText('Initialized - (1 day ago)')).toBeInTheDocument();
expect(screen.getByText('PodScheduled - (1 day ago)')).toBeInTheDocument();
expect(
screen.getByText('ContainersReady - (1 day ago)'),
).toBeInTheDocument();
expect(screen.getByText('Ready - (1 day ago)')).toBeInTheDocument();
expect(screen.queryAllByLabelText('Status ok')).toHaveLength(4);
expect(screen.queryByLabelText('Status warning')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Status error')).not.toBeInTheDocument();
});
it('show startup conditions - all fail', async () => {
const oneHourAgo = DateTime.now().minus({ hours: 1 }).toISO()!;
await renderInTestApp(
<PendingPodContent
{...{
pod: podWithConditions([
{
type: 'Initialized',
status: 'False',
reason: 'InitializedFailureReason',
message: 'reason why Initialized failed',
lastTransitionTime: oneHourAgo,
},
{
type: 'PodScheduled',
status: 'False',
reason: 'PodScheduledFailureReason',
message: 'reason why PodScheduled failed',
lastTransitionTime: oneHourAgo,
},
{
type: 'ContainersReady',
status: 'False',
reason: 'ContainersReadyFailureReason',
message: 'reason why ContainersReady failed',
lastTransitionTime: oneHourAgo,
},
{
type: 'Ready',
status: 'False',
reason: 'ReadyFailureReason',
message: 'reason why Ready failed',
lastTransitionTime: oneHourAgo,
},
]),
}}
/>,
);
expect(screen.getByText('Pod is Pending. Conditions:')).toBeInTheDocument();
expect(
screen.getByText(
'Initialized - (InitializedFailureReason 1 hour ago) - reason why Initialized failed',
),
).toBeInTheDocument();
expect(
screen.getByText(
'PodScheduled - (PodScheduledFailureReason 1 hour ago) - reason why PodScheduled failed',
),
).toBeInTheDocument();
expect(
screen.getByText(
'ContainersReady - (ContainersReadyFailureReason 1 hour ago) - reason why ContainersReady failed',
),
).toBeInTheDocument();
expect(
screen.getByText(
'Ready - (ReadyFailureReason 1 hour ago) - reason why Ready failed',
),
).toBeInTheDocument();
expect(screen.queryByLabelText('Status ok')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Status warning')).not.toBeInTheDocument();
expect(screen.queryAllByLabelText('Status error')).toHaveLength(4);
});
it('show startup conditions - show unknown', async () => {
const oneHourAgo = DateTime.now().minus({ hours: 1 }).toISO()!;
await renderInTestApp(
<PendingPodContent
{...{
pod: podWithConditions([
{
type: 'Initialized',
status: 'Unknown',
reason: 'InitializedUnknownReason',
message: 'dont know what is happening',
lastTransitionTime: oneHourAgo,
},
]),
}}
/>,
);
expect(screen.getByText('Pod is Pending. Conditions:')).toBeInTheDocument();
expect(
screen.getByText(
'Initialized - (1 hour ago) dont know what is happening',
),
).toBeInTheDocument();
expect(screen.queryByLabelText('Status ok')).not.toBeInTheDocument();
expect(screen.getByLabelText('Status warning')).toBeInTheDocument();
expect(screen.queryByLabelText('Status error')).not.toBeInTheDocument();
});
});
@@ -1,104 +0,0 @@
/*
* 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 { Grid, List, ListItem, Typography } from '@material-ui/core';
import { IPodCondition, Pod } from 'kubernetes-models/v1';
import {
StatusError,
StatusOK,
StatusWarning,
} from '@backstage/core-components';
import { DateTime } from 'luxon';
interface PodConditionProps {
condition: IPodCondition;
}
const PodCondition = ({ condition }: PodConditionProps) => {
return (
<>
{condition.status === 'False' && (
<StatusError>
{condition.type} - ({condition.reason}{' '}
{condition.lastTransitionTime &&
DateTime.fromISO(condition.lastTransitionTime).toRelative({
locale: 'en',
})}
) - {condition.message}{' '}
</StatusError>
)}
{condition.status === 'True' && (
<StatusOK>
{condition.type} - (
{condition.lastTransitionTime &&
DateTime.fromISO(condition.lastTransitionTime).toRelative({
locale: 'en',
})}
)
</StatusOK>
)}
{condition.status === 'Unknown' && (
<StatusWarning>
{condition.type} - (
{condition.lastTransitionTime &&
DateTime.fromISO(condition.lastTransitionTime).toRelative({
locale: 'en',
})}
) {condition.message}
</StatusWarning>
)}
</>
);
};
/**
* 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
const startupConditions = [
pod.status?.conditions?.find(c => c.type === 'PodScheduled'),
pod.status?.conditions?.find(c => c.type === 'Initialized'),
pod.status?.conditions?.find(c => c.type === 'ContainersReady'),
pod.status?.conditions?.find(c => c.type === 'Ready'),
].filter((c): c is IPodCondition => !!c); // filter out undefined
return (
<Grid container spacing={2}>
<Grid item xs={12}>
<Typography variant="h5">Pod is Pending. Conditions:</Typography>
<List>
{startupConditions.map(c => (
<ListItem key={c.type}>
<PodCondition condition={c} />
</ListItem>
))}
</List>
</Grid>
</Grid>
);
};
@@ -1,97 +0,0 @@
/*
* Copyright 2020 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 { screen } from '@testing-library/react';
import { TestApiProvider, renderInTestApp } from '@backstage/test-utils';
import '@testing-library/jest-dom';
import { PodDrawer } from './PodDrawer';
import { DiscoveryApi, discoveryApiRef } from '@backstage/core-plugin-api';
jest.mock('../../../hooks/useIsPodExecTerminalSupported');
describe('PodDrawer', () => {
it('Should show title and container names', async () => {
const mockDiscoveryApi: Partial<DiscoveryApi> = {
getBaseUrl: () => Promise.resolve('http://localhost'),
};
await renderInTestApp(
<TestApiProvider apis={[[discoveryApiRef, mockDiscoveryApi]]}>
<PodDrawer
{...({
open: true,
podAndErrors: {
clusterName: 'some-cluster-1',
pod: {
metadata: {
name: 'some-pod',
},
spec: {
containers: [
{
name: 'some-container',
},
],
},
status: {
podIP: '127.0.0.1',
containerStatuses: [
{
name: 'some-container',
},
],
},
},
errors: [
{
type: 'some-error',
severity: 10,
message: 'some error message',
occurrenceCount: 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'],
},
],
},
],
},
} as any)}
/>
</TestApiProvider>,
);
expect(screen.getAllByText('some-pod')).toHaveLength(3);
expect(screen.getByText('Pod (127.0.0.1)')).toBeInTheDocument();
expect(screen.getByText('YAML')).toBeInTheDocument();
expect(screen.getByText('Containers')).toBeInTheDocument();
expect(screen.getByText('some-container')).toBeInTheDocument();
expect(screen.getByText('some error message')).toBeInTheDocument();
});
});
@@ -1,189 +0,0 @@
/*
* Copyright 2020 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 { ItemCardGrid } from '@backstage/core-components';
import {
createStyles,
makeStyles,
Theme,
Grid,
Typography,
} from '@material-ui/core';
import { Pod } from 'kubernetes-models/v1';
import { ContainerCard } from './ContainerCard';
import { PodAndErrors } from '../types';
import { KubernetesDrawer } from '../../KubernetesDrawer';
import { PendingPodContent } from './PendingPodContent';
import { ErrorList } from '../ErrorList';
import { usePodMetrics } from '../../../hooks/usePodMetrics';
import { ResourceUtilization } from '../../ResourceUtilization';
import { bytesToMiB, formatMillicores } from '../../../utils/resources';
const useDrawerContentStyles = makeStyles((_theme: Theme) =>
createStyles({
header: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
},
content: {
height: '80%',
},
icon: {
fontSize: 20,
},
podoklist: {
width: '100%',
maxWidth: 360,
maxHeight: 360,
},
}),
);
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();
const podMetrics = usePodMetrics(podAndErrors.clusterName, podAndErrors.pod);
return (
<KubernetesDrawer
open={open}
drawerContentsHeader={
<Typography variant="subtitle1">
Pod{' '}
{podAndErrors.pod.status?.podIP &&
`(${podAndErrors.pod.status?.podIP})`}
</Typography>
}
kubernetesObject={podAndErrors.pod}
label={
<Typography variant="subtitle1">
{podAndErrors.pod.metadata?.name ?? 'unknown'}
</Typography>
}
>
<div className={classes.content}>
{podMetrics && (
<Grid container item xs={12}>
<Grid item xs={12}>
<Typography variant="h5">Resource utilization</Typography>
</Grid>
<Grid item xs={6}>
<ResourceUtilization
title="CPU requests"
usage={podMetrics.cpu.currentUsage}
total={podMetrics.cpu.requestTotal}
totalFormatted={formatMillicores(podMetrics.cpu.requestTotal)}
/>
<ResourceUtilization
title="CPU limits"
usage={podMetrics.cpu.currentUsage}
total={podMetrics.cpu.limitTotal}
totalFormatted={formatMillicores(podMetrics.cpu.limitTotal)}
/>
</Grid>
<Grid item xs={6}>
<ResourceUtilization
title="Memory requests"
usage={podMetrics.memory.currentUsage}
total={podMetrics.memory.requestTotal}
totalFormatted={bytesToMiB(podMetrics.memory.requestTotal)}
/>
<ResourceUtilization
title="Memory limits"
usage={podMetrics.memory.currentUsage}
total={podMetrics.memory.limitTotal}
totalFormatted={bytesToMiB(podMetrics.memory.requestTotal)}
/>
</Grid>
</Grid>
)}
{podAndErrors.pod.status?.phase === 'Pending' && (
<PendingPodContent pod={podAndErrors.pod} />
)}
{podAndErrors.pod.status?.containerStatuses?.length && (
<Grid container spacing={2}>
<Grid item xs={12}>
<Typography variant="h5">Containers</Typography>
</Grid>
<Grid item xs={12}>
<ItemCardGrid>
{podAndErrors.pod.status?.containerStatuses?.map(
(containerStatus, i) => {
const containerSpec = getContainerSpecByName(
podAndErrors.pod,
containerStatus.name,
);
const containerMetrics = (
podMetrics?.containers ?? []
).find(c => c.container === containerStatus.name);
return (
<ContainerCard
key={`container-card-${podAndErrors.pod.metadata?.name}-${i}`}
containerMetrics={containerMetrics}
podScope={{
podName: podAndErrors.pod.metadata?.name ?? 'unknown',
podNamespace:
podAndErrors.pod.metadata?.namespace ?? 'unknown',
clusterName: podAndErrors.clusterName,
}}
containerSpec={containerSpec}
containerStatus={containerStatus}
/>
);
},
)}
</ItemCardGrid>
</Grid>
{podAndErrors.errors.length > 0 && (
<Grid item xs={12}>
<Typography variant="h5">Errors</Typography>
</Grid>
)}
{podAndErrors.errors.length > 0 && (
<Grid item xs={12}>
<ErrorList podAndErrors={[podAndErrors]} />
</Grid>
)}
</Grid>
)}
</div>
</KubernetesDrawer>
);
};
@@ -1,18 +0,0 @@
/*
* 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 './PodDrawer';
export * from './ContainerCard';
export * from './PendingPodContent';
@@ -1,84 +0,0 @@
/*
* 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 {
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';
/**
* Props for PodLogs
*
* @public
*/
export interface PodLogsProps {
containerScope: ContainerScope;
previous?: boolean;
}
/**
* Shows the logs for the given pod
*
* @public
*/
export const PodLogs: React.FC<PodLogsProps> = ({
containerScope,
previous,
}: PodLogsProps) => {
const { value, error, loading } = usePodLogs({
containerScope,
previous,
});
return (
<>
{error && (
<DismissableBanner
{...{
message: error.message,
variant: 'error',
fixed: false,
}}
id="pod-logs"
/>
)}
<Paper
elevation={1}
style={{ height: '100%', width: '100%', minHeight: '55rem' }}
>
{loading && <Skeleton variant="rect" width="100%" height="100%" />}
{!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>
</>
);
};
@@ -1,100 +0,0 @@
/*
* 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,
createStyles,
Dialog,
DialogContent,
DialogTitle,
IconButton,
makeStyles,
Theme,
} from '@material-ui/core';
import SubjectIcon from '@material-ui/icons/Subject';
import CloseIcon from '@material-ui/icons/Close';
import { PodLogs } from './PodLogs';
import { ContainerScope } from './types';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
closeButton: {
position: 'absolute',
right: theme.spacing(1),
top: theme.spacing(1),
color: theme.palette.grey[500],
},
}),
);
/**
* Props for PodLogsDialog
*
* @public
*/
export interface PodLogsDialogProps {
containerScope: ContainerScope;
}
/**
* Shows the logs for the given pod in a Dialog
*
* @public
*/
export const PodLogsDialog = ({ containerScope }: PodLogsDialogProps) => {
const classes = useStyles();
const [open, setOpen] = useState(false);
const openDialog = () => {
setOpen(true);
};
const closeDialog = () => {
setOpen(false);
};
return (
<>
<Dialog maxWidth="xl" fullWidth open={open} onClose={closeDialog}>
<DialogTitle id="dialog-title">
{containerScope.podName} - {containerScope.containerName} logs on
cluster {containerScope.clusterName}
<IconButton
aria-label="close"
className={classes.closeButton}
onClick={closeDialog}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent>
<PodLogs containerScope={containerScope} />
</DialogContent>
</Dialog>
<Button
variant="outlined"
aria-label="get logs"
component="label"
onClick={openDialog}
startIcon={<SubjectIcon />}
>
Logs
</Button>
</>
);
};
-19
View File
@@ -1,19 +0,0 @@
/*
* 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 './PodLogs';
export * from './PodLogsDialog';
export * from './usePodLogs';
export * from './types';
-35
View File
@@ -1,35 +0,0 @@
/*
* 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.
*/
/**
* 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;
}
@@ -1,48 +0,0 @@
/*
* 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 useAsync from 'react-use/lib/useAsync';
import { ContainerScope } from './types';
import { useApi } from '@backstage/core-plugin-api';
import { kubernetesProxyApiRef } from '../../../api';
/**
* Arguments for usePodLogs
*
* @public
*/
export interface PodLogsOptions {
containerScope: ContainerScope;
previous?: boolean;
}
/**
* 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: containerScope.podName,
namespace: containerScope.podNamespace,
containerName: containerScope.containerName,
clusterName: containerScope.clusterName,
previous,
});
}, [JSON.stringify(containerScope)]);
};
-181
View File
@@ -1,181 +0,0 @@
/*
* Copyright 2020 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 { screen } from '@testing-library/react';
import * as pod from './__fixtures__/pod.json';
import * as crashingPod from './__fixtures__/crashing-pod.json';
import { renderInTestApp } from '@backstage/test-utils';
import { PodsTable, READY_COLUMNS, RESOURCE_COLUMNS } from './PodsTable';
import { kubernetesProviders } from '../../hooks/test-utils';
import { ClientPodStatus } from '@backstage/plugin-kubernetes-common';
describe('PodsTable', () => {
it('should render pod', async () => {
await renderInTestApp(<PodsTable pods={[pod as any]} />);
// titles
expect(screen.getByText('name')).toBeInTheDocument();
expect(screen.getByText('phase')).toBeInTheDocument();
expect(screen.getByText('status')).toBeInTheDocument();
// values
expect(screen.getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument();
expect(screen.getByText('Running')).toBeInTheDocument();
expect(screen.getByText('OK')).toBeInTheDocument();
});
it('should render pod with extra columns', async () => {
await renderInTestApp(
<PodsTable pods={[pod as any]} extraColumns={[READY_COLUMNS]} />,
);
// titles
expect(screen.getByText('name')).toBeInTheDocument();
expect(screen.getByText('phase')).toBeInTheDocument();
expect(screen.getByText('containers ready')).toBeInTheDocument();
expect(screen.getByText('total restarts')).toBeInTheDocument();
expect(screen.getByText('status')).toBeInTheDocument();
// values
expect(screen.getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument();
expect(screen.getByText('Running')).toBeInTheDocument();
expect(screen.getByText('1/1')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('OK')).toBeInTheDocument();
});
it('should render pod, with metrics context', async () => {
const clusterToClientPodStatus = new Map<string, ClientPodStatus[]>();
clusterToClientPodStatus.set('some-cluster', [
{
pod: {
metadata: {
name: 'dice-roller-6c8646bfd-2m5hv',
namespace: 'default',
},
},
memory: {
currentUsage: '1069056',
requestTotal: '67108864',
limitTotal: '134217728',
},
cpu: {
currentUsage: 0.4966115,
requestTotal: 0.05,
limitTotal: 0.05,
},
},
] as any);
const wrapper = kubernetesProviders(
undefined,
undefined,
clusterToClientPodStatus,
{
name: 'some-cluster',
},
);
await renderInTestApp(
wrapper(
<PodsTable
pods={[pod as any]}
extraColumns={[READY_COLUMNS, RESOURCE_COLUMNS]}
/>,
),
);
// titles
expect(screen.getByText('name')).toBeInTheDocument();
expect(screen.getByText('phase')).toBeInTheDocument();
expect(screen.getByText('containers ready')).toBeInTheDocument();
expect(screen.getByText('total restarts')).toBeInTheDocument();
expect(screen.getByText('status')).toBeInTheDocument();
expect(screen.getByText('CPU usage %')).toBeInTheDocument();
expect(screen.getByText('Memory usage %')).toBeInTheDocument();
// values
expect(screen.getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument();
expect(screen.getByText('Running')).toBeInTheDocument();
expect(screen.getByText('1/1')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('OK')).toBeInTheDocument();
expect(screen.getByText('requests: 99% of 50m')).toBeInTheDocument();
expect(screen.getByText('limits: 99% of 50m')).toBeInTheDocument();
expect(screen.getByText('requests: 1% of 64MiB')).toBeInTheDocument();
expect(screen.getByText('limits: 0% of 128MiB')).toBeInTheDocument();
});
it('should render placeholder when empty metrics context', async () => {
const podNameToClientPodStatus = new Map<string, ClientPodStatus[]>();
const wrapper = kubernetesProviders(
undefined,
undefined,
podNameToClientPodStatus,
);
await renderInTestApp(
wrapper(
<PodsTable
pods={[pod as any]}
extraColumns={[READY_COLUMNS, RESOURCE_COLUMNS]}
/>,
),
);
// titles
expect(screen.getByText('name')).toBeInTheDocument();
expect(screen.getByText('phase')).toBeInTheDocument();
expect(screen.getByText('containers ready')).toBeInTheDocument();
expect(screen.getByText('total restarts')).toBeInTheDocument();
expect(screen.getByText('status')).toBeInTheDocument();
expect(screen.getByText('CPU usage %')).toBeInTheDocument();
expect(screen.getByText('Memory usage %')).toBeInTheDocument();
// values
expect(screen.getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument();
expect(screen.getByText('Running')).toBeInTheDocument();
expect(screen.getByText('1/1')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('OK')).toBeInTheDocument();
expect(screen.getAllByText('unknown')).toHaveLength(2);
});
it('should render crashing pod with extra columns', async () => {
await renderInTestApp(
<PodsTable pods={[crashingPod as any]} extraColumns={[READY_COLUMNS]} />,
);
// titles
expect(screen.getByText('name')).toBeInTheDocument();
expect(screen.getByText('phase')).toBeInTheDocument();
expect(screen.getByText('containers ready')).toBeInTheDocument();
expect(screen.getByText('total restarts')).toBeInTheDocument();
expect(screen.getByText('status')).toBeInTheDocument();
// values
expect(
screen.getByText('dice-roller-canary-7d64cd756c-55rfq'),
).toBeInTheDocument();
expect(screen.getByText('Running')).toBeInTheDocument();
expect(screen.getByText('1/3')).toBeInTheDocument();
expect(screen.getByText('76')).toBeInTheDocument();
expect(screen.getByText('Container: side-car')).toBeInTheDocument();
expect(screen.getByText('Container: other-side-car')).toBeInTheDocument();
expect(screen.getAllByText('CrashLoopBackOff')).toHaveLength(2);
});
});
-157
View File
@@ -1,157 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useContext } from 'react';
import { PodDrawer } from './PodDrawer';
import {
containersReady,
containerStatuses,
podStatusToCpuUtil,
podStatusToMemoryUtil,
totalRestarts,
} from '../../utils/pod';
import { Table, TableColumn } from '@backstage/core-components';
import { ClusterContext } from '../../hooks/Cluster';
import { useMatchingErrors } from '../../hooks/useMatchingErrors';
import { Pod } from 'kubernetes-models/v1/Pod';
import { V1Pod } from '@kubernetes/client-node';
import { usePodMetrics } from '../../hooks/usePodMetrics';
import { Typography } from '@material-ui/core';
export const READY_COLUMNS: PodColumns = 'READY';
export const RESOURCE_COLUMNS: PodColumns = 'RESOURCE';
export type PodColumns = 'READY' | 'RESOURCE';
type PodsTablesProps = {
pods: Pod | V1Pod[];
extraColumns?: PodColumns[];
children?: React.ReactNode;
};
const READY: TableColumn<Pod>[] = [
{
title: 'containers ready',
align: 'center',
render: containersReady,
width: 'auto',
},
{
title: 'total restarts',
align: 'center',
render: totalRestarts,
type: 'numeric',
width: 'auto',
},
];
const PodDrawerTrigger = ({ pod }: { pod: Pod }) => {
const cluster = useContext(ClusterContext);
const errors = useMatchingErrors({
kind: 'Pod',
apiVersion: 'v1',
metadata: pod.metadata,
});
return (
<PodDrawer
podAndErrors={{
pod: pod as any,
clusterName: cluster.name,
errors: errors,
}}
/>
);
};
const Cpu = ({ clusterName, pod }: { clusterName: string; pod: Pod }) => {
const metrics = usePodMetrics(clusterName, pod);
if (!metrics) {
return <Typography>unknown</Typography>;
}
return <>{podStatusToCpuUtil(metrics)}</>;
};
const Memory = ({ clusterName, pod }: { clusterName: string; pod: Pod }) => {
const metrics = usePodMetrics(clusterName, pod);
if (!metrics) {
return <Typography>unknown</Typography>;
}
return <>{podStatusToMemoryUtil(metrics)}</>;
};
export const PodsTable = ({ pods, extraColumns = [] }: PodsTablesProps) => {
const cluster = useContext(ClusterContext);
const defaultColumns: TableColumn<Pod>[] = [
{
title: 'name',
highlight: true,
render: (pod: Pod) => {
return <PodDrawerTrigger pod={pod} />;
},
},
{
title: 'phase',
render: (pod: Pod) => pod.status?.phase ?? 'unknown',
width: 'auto',
},
{
title: 'status',
render: containerStatuses,
},
];
const columns: TableColumn<Pod>[] = [...defaultColumns];
if (extraColumns.includes(READY_COLUMNS)) {
columns.push(...READY);
}
if (extraColumns.includes(RESOURCE_COLUMNS)) {
const resourceColumns: TableColumn<Pod>[] = [
{
title: 'CPU usage %',
render: (pod: Pod) => {
return <Cpu clusterName={cluster.name} pod={pod} />;
},
width: 'auto',
},
{
title: 'Memory usage %',
render: (pod: Pod) => {
return <Memory clusterName={cluster.name} pod={pod} />;
},
width: 'auto',
},
];
columns.push(...resourceColumns);
}
const tableStyle = {
minWidth: '0',
width: '100%',
};
return (
<div style={tableStyle}>
<Table
options={{ paging: true, search: false, emptyRowsWhenPaging: false }}
data={pods as Pod[]}
columns={columns}
/>
</div>
);
};
@@ -1,233 +0,0 @@
{
"metadata": {
"creationTimestamp": "2020-09-25T10:34:01.000Z",
"generateName": "dice-roller-canary-7d64cd756c-",
"labels": {
"app": "dice-roller-canary",
"backstage.io/kubernetes-id": "dice-roller",
"pod-template-hash": "7d64cd756c"
},
"name": "dice-roller-canary-7d64cd756c-55rfq",
"namespace": "default",
"ownerReferences": [
{
"apiVersion": "apps/v1",
"blockOwnerDeletion": true,
"controller": true,
"kind": "ReplicaSet",
"name": "dice-roller-canary-7d64cd756c",
"uid": "9208395b-a9a7-4e46-b881-6a189f7fbdb0"
}
],
"resourceVersion": "620452",
"selfLink": "/api/v1/namespaces/default/pods/dice-roller-canary-7d64cd756c-55rfq",
"uid": "65ad28e3-5d51-4b4b-9bf8-4cb069803034"
},
"spec": {
"containers": [
{
"image": "nginx:1.14.2",
"imagePullPolicy": "IfNotPresent",
"name": "nginx",
"ports": [
{
"containerPort": 80,
"protocol": "TCP"
}
],
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File",
"volumeMounts": [
{
"mountPath": "/var/run/secrets/kubernetes.io/serviceaccount",
"name": "default-token-5gctn",
"readOnly": true
}
]
},
{
"image": "nginx:1.14.2",
"imagePullPolicy": "IfNotPresent",
"name": "side-car",
"ports": [
{
"containerPort": 81,
"protocol": "TCP"
}
],
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File",
"volumeMounts": [
{
"mountPath": "/var/run/secrets/kubernetes.io/serviceaccount",
"name": "default-token-5gctn",
"readOnly": true
}
]
},
{
"image": "nginx:1.14.2",
"imagePullPolicy": "IfNotPresent",
"name": "other-side-car",
"ports": [
{
"containerPort": 82,
"protocol": "TCP"
}
],
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File",
"volumeMounts": [
{
"mountPath": "/var/run/secrets/kubernetes.io/serviceaccount",
"name": "default-token-5gctn",
"readOnly": true
}
]
}
],
"dnsPolicy": "ClusterFirst",
"enableServiceLinks": true,
"nodeName": "minikube",
"priority": 0,
"restartPolicy": "Always",
"schedulerName": "default-scheduler",
"securityContext": {},
"serviceAccount": "default",
"serviceAccountName": "default",
"terminationGracePeriodSeconds": 30,
"tolerations": [
{
"effect": "NoExecute",
"key": "node.kubernetes.io/not-ready",
"operator": "Exists",
"tolerationSeconds": 300
},
{
"effect": "NoExecute",
"key": "node.kubernetes.io/unreachable",
"operator": "Exists",
"tolerationSeconds": 300
}
],
"volumes": [
{
"name": "default-token-5gctn",
"secret": {
"defaultMode": 420,
"secretName": "default-token-5gctn"
}
}
]
},
"status": {
"conditions": [
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T10:34:01.000Z",
"status": "True",
"type": "Initialized"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T14:18:53.000Z",
"message": "containers with unready status: [side-car other-side-car]",
"reason": "ContainersNotReady",
"status": "False",
"type": "Ready"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T14:18:53.000Z",
"message": "containers with unready status: [side-car other-side-car]",
"reason": "ContainersNotReady",
"status": "False",
"type": "ContainersReady"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T10:34:01.000Z",
"status": "True",
"type": "PodScheduled"
}
],
"containerStatuses": [
{
"containerID": "docker://6ce15178d114a85f3d2e832de45c3355ab5b71ed5f4d4d225ee1c83bf07f69d9",
"image": "nginx:1.14.2",
"imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d",
"lastState": {},
"name": "nginx",
"ready": true,
"restartCount": 0,
"started": true,
"state": {
"running": {
"startedAt": "2020-09-25T10:34:01.000Z"
}
}
},
{
"containerID": "docker://b3ce93d7f90bfe22558c61d2505b8473580574accdebb5fa4e51c0729c3511f4",
"image": "nginx:1.14.2",
"imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d",
"lastState": {
"terminated": {
"containerID": "docker://b3ce93d7f90bfe22558c61d2505b8473580574accdebb5fa4e51c0729c3511f4",
"exitCode": 1,
"finishedAt": "2020-09-25T14:18:52.000Z",
"reason": "Error",
"startedAt": "2020-09-25T14:18:50.000Z"
}
},
"name": "other-side-car",
"ready": false,
"restartCount": 38,
"started": false,
"state": {
"waiting": {
"message": "back-off 5m0s restarting failed container=other-side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)",
"reason": "CrashLoopBackOff"
}
}
},
{
"containerID": "docker://b7f0e65a2b8ab48c5f234616cfe8286aa96b55c3ef09c5cfbc4cdbe67a96f8cb",
"image": "nginx:1.14.2",
"imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d",
"lastState": {
"terminated": {
"containerID": "docker://b7f0e65a2b8ab48c5f234616cfe8286aa96b55c3ef09c5cfbc4cdbe67a96f8cb",
"exitCode": 1,
"finishedAt": "2020-09-25T14:18:52.000Z",
"reason": "Error",
"startedAt": "2020-09-25T14:18:50.000Z"
}
},
"name": "side-car",
"ready": false,
"restartCount": 38,
"started": false,
"state": {
"waiting": {
"message": "back-off 5m0s restarting failed container=side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)",
"reason": "CrashLoopBackOff"
}
}
}
],
"hostIP": "192.168.64.2",
"phase": "Running",
"podIP": "172.17.0.16",
"podIPs": [
{
"ip": "172.17.0.16"
}
],
"qosClass": "BestEffort",
"startTime": "2020-09-25T10:34:01.000Z"
}
}
@@ -1,139 +0,0 @@
{
"metadata": {
"creationTimestamp": "2020-09-25T09:58:50.000Z",
"generateName": "dice-roller-6c8646bfd-",
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"pod-template-hash": "6c8646bfd"
},
"name": "dice-roller-6c8646bfd-2m5hv",
"namespace": "default",
"ownerReferences": [
{
"apiVersion": "apps/v1",
"blockOwnerDeletion": true,
"controller": true,
"kind": "ReplicaSet",
"name": "dice-roller-6c8646bfd",
"uid": "5126c354-4310-4e23-a9e4-c9b87cb69792"
}
],
"resourceVersion": "593216",
"selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-2m5hv",
"uid": "aadb71c0-36fa-43e3-b38a-162f134d4359"
},
"spec": {
"containers": [
{
"image": "nginx:1.14.2",
"imagePullPolicy": "IfNotPresent",
"name": "nginx",
"ports": [
{
"containerPort": 80,
"protocol": "TCP"
}
],
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File",
"volumeMounts": [
{
"mountPath": "/var/run/secrets/kubernetes.io/serviceaccount",
"name": "default-token-5gctn",
"readOnly": true
}
]
}
],
"dnsPolicy": "ClusterFirst",
"enableServiceLinks": true,
"nodeName": "minikube",
"priority": 0,
"restartPolicy": "Always",
"schedulerName": "default-scheduler",
"securityContext": {},
"serviceAccount": "default",
"serviceAccountName": "default",
"terminationGracePeriodSeconds": 30,
"tolerations": [
{
"effect": "NoExecute",
"key": "node.kubernetes.io/not-ready",
"operator": "Exists",
"tolerationSeconds": 300
},
{
"effect": "NoExecute",
"key": "node.kubernetes.io/unreachable",
"operator": "Exists",
"tolerationSeconds": 300
}
],
"volumes": [
{
"name": "default-token-5gctn",
"secret": {
"defaultMode": 420,
"secretName": "default-token-5gctn"
}
}
]
},
"status": {
"conditions": [
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:50.000Z",
"status": "True",
"type": "Initialized"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:55.000Z",
"status": "True",
"type": "Ready"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:55.000Z",
"status": "True",
"type": "ContainersReady"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:50.000Z",
"status": "True",
"type": "PodScheduled"
}
],
"containerStatuses": [
{
"containerID": "docker://aa4489297c34c48bb33c18474a8d2b33854a82ed42155680b259f635f556ce70",
"image": "nginx:1.14.2",
"imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d",
"lastState": {},
"name": "nginx",
"ready": true,
"restartCount": 0,
"started": true,
"state": {
"running": {
"startedAt": "2020-09-25T09:58:53.000Z"
}
}
}
],
"hostIP": "192.168.64.2",
"phase": "Running",
"podIP": "172.17.0.11",
"podIPs": [
{
"ip": "172.17.0.11"
}
],
"qosClass": "BestEffort",
"startTime": "2020-09-25T09:58:50.000Z"
}
}

Some files were not shown because too many files have changed in this diff Show More