feat: add Kubernetes pod logs component (#17120)

* refactor: rename kubernetes structured metadata table

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

* feat: add pod logs components

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

* refactor: more generic proxy token generating

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

* tests

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

* chore: changeset

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

* fix: tsc fix

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

* docs: api report

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

* fix: remove event/errors mention

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

* feat: pending pod conditions

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

* fix: missed file

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

* fix: merge master

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

* fix: remove unneeded code

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

* feat: add proxy client

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

* docs: breaking change

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

* Update plugins/kubernetes/src/api/types.ts

Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com>
Signed-off-by: Matthew Clarke <matthewclarke47@gmail.com>

* Update plugins/kubernetes/src/api/types.ts

Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com>
Signed-off-by: Matthew Clarke <matthewclarke47@gmail.com>

* fix: PR feedback

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

* fix: test

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

* fix: PR feedback

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

* fix: PR feedback

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

* fix: todo

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

* fix: api reports

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

---------

Signed-off-by: Matthew Clarke <mclarke@spotify.com>
Signed-off-by: Matthew Clarke <matthewclarke47@gmail.com>
Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Matthew Clarke
2023-04-28 10:34:33 -04:00
committed by GitHub
parent cb31ea1da1
commit 280ec10c18
40 changed files with 1860 additions and 490 deletions
@@ -0,0 +1,54 @@
/*
* 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 { KubernetesProxyClient } from './KubernetesProxyClient';
describe('KubernetesProxyClient', () => {
let proxy: KubernetesProxyClient;
const callProxyMock = jest.fn();
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',
});
});
});
@@ -0,0 +1,69 @@
/*
* 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';
/**
* 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();
}
async getPodLogs({
podName,
namespace,
clusterName,
containerName,
}: {
podName: string;
namespace: string;
clusterName: string;
containerName: string;
}): Promise<{ text: string }> {
const params = new URLSearchParams({
container: containerName,
});
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 }));
}
}
+3 -2
View File
@@ -14,6 +14,7 @@
* limitations under the License.
*/
export { kubernetesApiRef } from './types';
export type { KubernetesApi } from './types';
export { kubernetesApiRef, kubernetesProxyApiRef } from './types';
export type { KubernetesApi, KubernetesProxyApi } from './types';
export { KubernetesBackendClient } from './KubernetesBackendClient';
export { KubernetesProxyClient } from './KubernetesProxyClient';
+13
View File
@@ -26,6 +26,10 @@ 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,
@@ -49,3 +53,12 @@ export interface KubernetesApi {
init?: RequestInit;
}): Promise<Response>;
}
export interface KubernetesProxyApi {
getPodLogs(request: {
podName: string;
namespace: string;
clusterName: string;
containerName: string;
}): Promise<{ text: string }>;
}
@@ -15,7 +15,7 @@
*/
import React from 'react';
import { V1CronJob } from '@kubernetes/client-node';
import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer';
import { KubernetesStructuredMetadataTableDrawer } from '../KubernetesDrawer';
import { Typography, Grid, Chip } from '@material-ui/core';
export const CronJobDrawer = ({
@@ -27,7 +27,7 @@ export const CronJobDrawer = ({
}) => {
const namespace = cronJob.metadata?.namespace;
return (
<KubernetesDrawer
<KubernetesStructuredMetadataTableDrawer
object={cronJob}
expanded={expanded}
kind="CronJob"
@@ -62,6 +62,6 @@ export const CronJobDrawer = ({
</Grid>
)}
</Grid>
</KubernetesDrawer>
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -15,7 +15,7 @@
*/
import React from 'react';
import { KubernetesDrawer } from '../../KubernetesDrawer/KubernetesDrawer';
import { KubernetesStructuredMetadataTableDrawer } from '../../KubernetesDrawer';
import { Typography, Grid } from '@material-ui/core';
export const RolloutDrawer = ({
@@ -26,7 +26,7 @@ export const RolloutDrawer = ({
expanded?: boolean;
}) => {
return (
<KubernetesDrawer
<KubernetesStructuredMetadataTableDrawer
object={rollout}
expanded={expanded}
kind="Rollout"
@@ -50,6 +50,6 @@ export const RolloutDrawer = ({
</Typography>
</Grid>
</Grid>
</KubernetesDrawer>
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -15,7 +15,7 @@
*/
import React from 'react';
import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer';
import { KubernetesStructuredMetadataTableDrawer } from '../KubernetesDrawer';
import { Typography, Grid } from '@material-ui/core';
const capitalize = (str: string) =>
@@ -33,7 +33,7 @@ export const DefaultCustomResourceDrawer = ({
const capitalizedName = capitalize(customResourceName);
return (
<KubernetesDrawer
<KubernetesStructuredMetadataTableDrawer
object={customResource}
expanded={expanded}
kind={capitalizedName}
@@ -57,6 +57,6 @@ export const DefaultCustomResourceDrawer = ({
</Typography>
</Grid>
</Grid>
</KubernetesDrawer>
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -16,7 +16,7 @@
import React from 'react';
import { V1Deployment } from '@kubernetes/client-node';
import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer';
import { KubernetesStructuredMetadataTableDrawer } from '../KubernetesDrawer';
import { renderCondition } from '../../utils/pod';
import { Typography, Grid, Chip } from '@material-ui/core';
@@ -29,7 +29,7 @@ export const DeploymentDrawer = ({
}) => {
const namespace = deployment.metadata?.namespace;
return (
<KubernetesDrawer
<KubernetesStructuredMetadataTableDrawer
object={deployment}
expanded={expanded}
kind="Deployment"
@@ -73,6 +73,6 @@ export const DeploymentDrawer = ({
</Grid>
)}
</Grid>
</KubernetesDrawer>
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -16,7 +16,7 @@
import React from 'react';
import { V1HorizontalPodAutoscaler } from '@kubernetes/client-node';
import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer';
import { KubernetesStructuredMetadataTableDrawer } from '../KubernetesDrawer';
/** @public */
export const HorizontalPodAutoscalerDrawer = (props: {
@@ -27,7 +27,7 @@ export const HorizontalPodAutoscalerDrawer = (props: {
const { hpa, expanded, children } = props;
return (
<KubernetesDrawer
<KubernetesStructuredMetadataTableDrawer
kind="HorizontalPodAutoscaler"
object={hpa}
expanded={expanded}
@@ -45,6 +45,6 @@ export const HorizontalPodAutoscalerDrawer = (props: {
}}
>
{children}
</KubernetesDrawer>
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -16,7 +16,7 @@
import React from 'react';
import { V1Ingress } from '@kubernetes/client-node';
import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer';
import { KubernetesStructuredMetadataTableDrawer } from '../KubernetesDrawer';
import { Typography, Grid } from '@material-ui/core';
export const IngressDrawer = ({
@@ -27,7 +27,7 @@ export const IngressDrawer = ({
expanded?: boolean;
}) => {
return (
<KubernetesDrawer
<KubernetesStructuredMetadataTableDrawer
object={ingress}
expanded={expanded}
kind="Ingress"
@@ -53,6 +53,6 @@ export const IngressDrawer = ({
</Typography>
</Grid>
</Grid>
</KubernetesDrawer>
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -15,7 +15,7 @@
*/
import React from 'react';
import { V1Job } from '@kubernetes/client-node';
import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer';
import { KubernetesStructuredMetadataTableDrawer } from '../KubernetesDrawer';
import { Typography, Grid } from '@material-ui/core';
export const JobDrawer = ({
@@ -26,7 +26,7 @@ export const JobDrawer = ({
expanded?: boolean;
}) => {
return (
<KubernetesDrawer
<KubernetesStructuredMetadataTableDrawer
object={job}
expanded={expanded}
kind="Job"
@@ -57,6 +57,6 @@ export const JobDrawer = ({
</Typography>
</Grid>
</Grid>
</KubernetesDrawer>
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Backstage Authors
* 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.
@@ -13,36 +13,115 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ChangeEvent, useState } from 'react';
import React, { ChangeEvent, useContext, useState } from 'react';
import { IObjectMeta } from '@kubernetes-models/apimachinery/apis/meta/v1/ObjectMeta';
import {
Button,
Typography,
makeStyles,
IconButton,
createStyles,
Theme,
Drawer,
Switch,
FormControlLabel,
FormGroup,
makeStyles,
Theme,
Grid,
IconButton,
Switch,
Typography,
Button,
withStyles,
FormControlLabel,
} 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 jsYaml from 'js-yaml';
import {
LinkButton as BackstageButton,
CodeSnippet,
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 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({
@@ -54,32 +133,7 @@ const useDrawerStyles = makeStyles((theme: Theme) =>
}),
);
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({
const DrawerButton = withStyles({
root: {
padding: '6px 5px',
},
@@ -88,203 +142,15 @@ const PodDrawerButton = withStyles({
},
})(Button);
type ErrorPanelProps = {
cluster: ClusterAttributes;
errorMessage?: string;
children?: React.ReactNode;
};
export const ErrorPanel = ({ 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 KubernetesDrawerContentProps<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 KubernetesDrawerContent = <T extends KubernetesDrawerable>({
toggleDrawer,
object,
renderObject,
kind,
}: KubernetesDrawerContentProps<T>) => {
const [isYaml, setIsYaml] = useState<boolean>(false);
// 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);
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
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
>
<Grid item>
<Typography variant="h5">
{object.metadata?.name ?? 'unknown name'}
</Typography>
</Grid>
<Grid item>
<Typography color="textSecondary" variant="body1">
{kind}
</Typography>
</Grid>
</Grid>
<IconButton
key="dismiss"
title="Close the drawer"
onClick={e => toggleDrawer(e, false)}
color="inherit"
>
<Close className={classes.icon} />
</IconButton>
</div>
{errorMessage && (
<div className={classes.errorMessage}>
<ErrorPanel 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>
<FormGroup className={classes.options}>
<FormControlLabel
control={
<Switch
checked={isYaml}
onChange={event => {
setIsYaml(event.target.checked);
}}
name="YAML"
/>
}
label="YAML"
/>
<FormControlLabel
control={
<Switch
checked={isYaml && managedFields}
onChange={event => {
if (isYaml) {
setManagedFields(event.target.checked);
}
}}
name="Managed Fields"
/>
}
label="Managed Fields"
/>
</FormGroup>
</div>
<div className={classes.content}>
{isYaml && (
<CodeSnippet
language="yaml"
text={jsYaml.dump(object, {
replacer: (key: string, value: string): any => {
if (!managedFields) {
return key === 'managedFields' ? undefined : value;
}
return value;
},
})}
/>
)}
{!isYaml && (
<StructuredMetadataTable
metadata={renderObject(replaceNullsWithUndefined(object))}
/>
)}
</div>
</>
);
};
interface KubernetesDrawerProps<T extends KubernetesDrawerable> {
object: T;
renderObject: (obj: T) => object;
buttonVariant?: 'h5' | 'subtitle2';
kind: string;
expanded?: boolean;
children?: React.ReactNode;
}
export const KubernetesDrawer = <T extends KubernetesDrawerable>({
object,
renderObject,
kind,
buttonVariant = 'subtitle2',
expanded = false,
export const KubernetesDrawer = ({
open,
label,
drawerContentsHeader,
kubernetesObject,
children,
}: KubernetesDrawerProps<T>) => {
const [isOpen, setIsOpen] = useState(expanded);
}: KubernetesDrawerProps) => {
const classes = useDrawerStyles();
const [isOpen, setIsOpen] = useState<boolean>(open ?? false);
const toggleDrawer = (e: ChangeEvent<{}>, newValue: boolean) => {
e.stopPropagation();
@@ -293,18 +159,7 @@ export const KubernetesDrawer = <T extends KubernetesDrawerable>({
return (
<>
<PodDrawerButton
onClick={e => toggleDrawer(e, true)}
onFocus={event => event.stopPropagation()}
>
{children === undefined ? (
<Typography variant={buttonVariant}>
{object.metadata?.name ?? 'unknown object'}
</Typography>
) : (
children
)}
</PodDrawerButton>
<DrawerButton onClick={() => setIsOpen(true)}>{label}</DrawerButton>
<Drawer
classes={{
paper: classes.paper,
@@ -314,12 +169,14 @@ export const KubernetesDrawer = <T extends KubernetesDrawerable>({
onClose={(e: any) => toggleDrawer(e, false)}
onClick={event => event.stopPropagation()}
>
<KubernetesDrawerContent
kind={kind}
toggleDrawer={toggleDrawer}
object={object}
renderObject={renderObject}
/>
{isOpen && (
<KubernetesDrawerContent
header={drawerContentsHeader}
kubernetesObject={kubernetesObject}
children={children}
close={() => setIsOpen(false)}
/>
)}
</Drawer>
</>
);
@@ -0,0 +1,298 @@
/*
* 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>
</>
);
};
@@ -0,0 +1,60 @@
/*
* 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;
},
})}
/>
</>
);
};
@@ -14,4 +14,5 @@
* limitations under the License.
*/
export * from './KubernetesStructuredMetadataTableDrawer';
export * from './KubernetesDrawer';
@@ -1,99 +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 { render } from '@testing-library/react';
import * as pod from './__fixtures__/pod.json';
import * as crashingPod from './__fixtures__/crashing-pod.json';
import { textContentMatcher, wrapInTestApp } from '@backstage/test-utils';
import { PodDrawer } from './PodDrawer';
describe('PodDrawer', () => {
it('should render pod', async () => {
const { getByText, getAllByText } = render(
wrapInTestApp(<PodDrawer pod={pod as any} expanded />),
);
expect(getAllByText('dice-roller-6c8646bfd-2m5hv')).toHaveLength(2);
expect(getByText('Pod')).toBeInTheDocument();
expect(getByText('YAML')).toBeInTheDocument();
expect(getByText('Images')).toBeInTheDocument();
expect(getByText('nginx=nginx:1.14.2')).toBeInTheDocument();
expect(getByText('Phase')).toBeInTheDocument();
expect(getByText('Running')).toBeInTheDocument();
expect(getAllByText('Containers Ready')).toHaveLength(2);
expect(getByText('1/1')).toBeInTheDocument();
expect(getByText('Total Restarts')).toBeInTheDocument();
expect(getByText('0')).toBeInTheDocument();
expect(getByText('Container Statuses')).toBeInTheDocument();
expect(getByText('OK')).toBeInTheDocument();
expect(getByText('Initialized')).toBeInTheDocument();
expect(getByText('Ready')).toBeInTheDocument();
expect(getByText('Pod Scheduled')).toBeInTheDocument();
expect(getAllByText('True')).toHaveLength(4);
expect(getByText('Exposed Ports')).toBeInTheDocument();
expect(getByText('Nginx:')).toBeInTheDocument();
expect(
getByText(textContentMatcher('Container Port: 80')),
).toBeInTheDocument();
expect(getByText(textContentMatcher('Protocol: TCP'))).toBeInTheDocument();
});
it('should render crashing pod', async () => {
const { getByText, getAllByText } = render(
wrapInTestApp(<PodDrawer pod={crashingPod as any} expanded />),
);
expect(getAllByText('dice-roller-canary-7d64cd756c-55rfq')).toHaveLength(2);
expect(getByText('Pod')).toBeInTheDocument();
expect(getByText('YAML')).toBeInTheDocument();
expect(getByText('Images')).toBeInTheDocument();
expect(getByText('nginx=nginx:1.14.2')).toBeInTheDocument();
expect(getByText('other-side-car=nginx:1.14.2')).toBeInTheDocument();
expect(getByText('side-car=nginx:1.14.2')).toBeInTheDocument();
expect(getByText('Phase')).toBeInTheDocument();
expect(getByText('Running')).toBeInTheDocument();
expect(getAllByText('Containers Ready')).toHaveLength(2);
expect(getByText('1/3')).toBeInTheDocument();
expect(getByText('Total Restarts')).toBeInTheDocument();
expect(getByText('76')).toBeInTheDocument();
expect(getByText('Container Statuses')).toBeInTheDocument();
expect(getByText('Container: side-car')).toBeInTheDocument();
expect(getByText('Container: other-side-car')).toBeInTheDocument();
expect(getAllByText('CrashLoopBackOff')).toHaveLength(2);
expect(getByText('Initialized')).toBeInTheDocument();
expect(getByText('Ready')).toBeInTheDocument();
expect(getByText('Pod Scheduled')).toBeInTheDocument();
expect(getAllByText('True')).toHaveLength(2);
expect(getAllByText('False')).toHaveLength(2);
expect(
getAllByText('containers with unready status: [side-car other-side-car]'),
).toHaveLength(2);
expect(getByText('Exposed Ports')).toBeInTheDocument();
expect(getAllByText(textContentMatcher('Protocol: TCP'))).toHaveLength(3);
expect(getByText('Nginx:')).toBeInTheDocument();
expect(
getByText(textContentMatcher('Container Port: 80')),
).toBeInTheDocument();
expect(getByText('Side Car:')).toBeInTheDocument();
expect(
getByText(textContentMatcher('Container Port: 81')),
).toBeInTheDocument();
expect(getByText('Other Side Car:')).toBeInTheDocument();
expect(
getByText(textContentMatcher('Container Port: 82')),
).toBeInTheDocument();
});
});
-66
View File
@@ -1,66 +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 { V1Pod } from '@kubernetes/client-node';
import {
containersReady,
containerStatuses,
totalRestarts,
imageChips,
renderCondition,
} from '../../utils/pod';
import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer';
/** @public */
export const PodDrawer = (props: { pod: V1Pod; expanded?: boolean }) => {
const { pod, expanded } = props;
return (
<KubernetesDrawer
object={pod}
expanded={expanded}
kind="Pod"
renderObject={(podObject: V1Pod) => {
const phase = podObject.status?.phase ?? 'unknown';
const ports =
podObject.spec?.containers?.map(c => {
return {
[c.name]: c.ports,
};
}) ?? 'N/A';
const conditions = (podObject.status?.conditions ?? [])
.map(renderCondition)
.reduce((accum, next) => {
accum[next[0]] = next[1];
return accum;
}, {} as { [key: string]: React.ReactNode });
return {
images: imageChips(podObject),
phase: phase,
'Containers Ready': containersReady(podObject),
'Total Restarts': totalRestarts(podObject),
'Container Statuses': containerStatuses(podObject),
...conditions,
'Exposed ports': ports,
};
}}
/>
);
};
@@ -0,0 +1,137 @@
/*
* 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 '@testing-library/jest-dom';
import { wrapInTestApp } from '@backstage/test-utils';
import { ContainerCard } from './ContainerCard';
import { DateTime } from 'luxon';
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 () => {
const { getByText, queryByText, getAllByText } = render(
wrapInTestApp(
<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(getByText('Started: 1 hour ago')).toBeInTheDocument();
expect(getByText('Status: Running')).toBeInTheDocument();
expect(getByText('some-name')).toBeInTheDocument();
expect(getByText('gcr.io/some-proj/some-image')).toBeInTheDocument();
expect(getAllByText('✅')).toHaveLength(5);
expect(queryByText('❌')).toBeNull();
});
it('show unhealthy when all checks fail', async () => {
const { getByText, queryByText, getAllByText } = render(
wrapInTestApp(
<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(getByText('some-name')).toBeInTheDocument();
expect(getByText('gcr.io/some-proj/some-image')).toBeInTheDocument();
expect(getAllByText('❌')).toHaveLength(5);
expect(queryByText('✅')).toBeNull();
});
it('show correct checks for completed container', async () => {
const { getByText, queryByText, getAllByText } = render(
wrapInTestApp(
<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(getByText('some-name')).toBeInTheDocument();
expect(getByText('gcr.io/some-proj/some-image')).toBeInTheDocument();
expect(getByText('Started: 2 hours ago')).toBeInTheDocument();
expect(getByText('Completed: 1 hour ago')).toBeInTheDocument();
expect(
getByText('Execution time: 1 hour, 0 minutes, 0 seconds'),
).toBeInTheDocument();
expect(getByText('Status: Completed')).toBeInTheDocument();
expect(getAllByText('✅')).toHaveLength(2);
expect(queryByText('❌')).toBeNull();
});
});
@@ -0,0 +1,173 @@
/*
* 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 { IContainer, IContainerStatus } from 'kubernetes-models/v1';
import {
Card,
CardActions,
CardContent,
CardHeader,
Grid,
Typography,
} from '@material-ui/core';
import { DateTime } from 'luxon';
import { PodScope, PodLogsDialog } from '../PodLogs';
import { StructuredMetadataTable } from '@backstage/core-components';
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>
);
};
export interface ContainerCardProps {
podScope: PodScope;
containerSpec?: IContainer;
containerStatus: IContainerStatus;
}
export const ContainerCard: React.FC<ContainerCardProps> = ({
podScope,
containerSpec,
containerStatus,
}: 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>
</Grid>
</CardContent>
<CardActions disableSpacing>
<PodLogsDialog
podScope={{
containerName: containerStatus.name,
...podScope,
}}
/>
</CardActions>
</Card>
);
};
@@ -0,0 +1,185 @@
/*
* 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 '@testing-library/jest-dom';
import { wrapInTestApp } 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();
const { getByText, queryByLabelText, queryAllByLabelText } = render(
wrapInTestApp(
<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(getByText('Pod is Pending. Conditions:')).toBeInTheDocument();
expect(getByText('Initialized - (1 day ago)')).toBeInTheDocument();
expect(getByText('PodScheduled - (1 day ago)')).toBeInTheDocument();
expect(getByText('ContainersReady - (1 day ago)')).toBeInTheDocument();
expect(getByText('Ready - (1 day ago)')).toBeInTheDocument();
expect(queryAllByLabelText('Status ok')).toHaveLength(4);
expect(queryByLabelText('Status warning')).not.toBeInTheDocument();
expect(queryByLabelText('Status error')).not.toBeInTheDocument();
});
it('show startup conditions - all fail', async () => {
const oneHourAgo = DateTime.now().minus({ hours: 1 }).toISO();
const { getByText, queryByLabelText, queryAllByLabelText } = render(
wrapInTestApp(
<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(getByText('Pod is Pending. Conditions:')).toBeInTheDocument();
expect(
getByText(
'Initialized - (InitializedFailureReason 1 hour ago) - reason why Initialized failed',
),
).toBeInTheDocument();
expect(
getByText(
'PodScheduled - (PodScheduledFailureReason 1 hour ago) - reason why PodScheduled failed',
),
).toBeInTheDocument();
expect(
getByText(
'ContainersReady - (ContainersReadyFailureReason 1 hour ago) - reason why ContainersReady failed',
),
).toBeInTheDocument();
expect(
getByText(
'Ready - (ReadyFailureReason 1 hour ago) - reason why Ready failed',
),
).toBeInTheDocument();
expect(queryByLabelText('Status ok')).not.toBeInTheDocument();
expect(queryByLabelText('Status warning')).not.toBeInTheDocument();
expect(queryAllByLabelText('Status error')).toHaveLength(4);
});
it('show startup conditions - show unknown', async () => {
const oneHourAgo = DateTime.now().minus({ hours: 1 }).toISO();
const { getByText, queryByLabelText, getByLabelText } = render(
wrapInTestApp(
<PendingPodContent
{...{
pod: podWithConditions([
{
type: 'Initialized',
status: 'Unknown',
reason: 'InitializedUnknownReason',
message: 'dont know what is happening',
lastTransitionTime: oneHourAgo,
},
]),
}}
/>,
),
);
expect(getByText('Pod is Pending. Conditions:')).toBeInTheDocument();
expect(
getByText('Initialized - (1 hour ago) dont know what is happening'),
).toBeInTheDocument();
expect(queryByLabelText('Status ok')).not.toBeInTheDocument();
expect(getByLabelText('Status warning')).toBeInTheDocument();
expect(queryByLabelText('Status error')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,94 @@
/*
* 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;
}
export 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>
)}
</>
);
};
interface PendingPodContentProps {
pod: Pod;
}
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>
);
};
@@ -0,0 +1,67 @@
/*
* 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 { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import '@testing-library/jest-dom';
import { PodDrawer } from '.';
describe('PodDrawer', () => {
it('Should show title and container names', async () => {
const { getAllByText, getByText } = render(
wrapInTestApp(
<PodDrawer
{...({
open: true,
podAndErrors: {
clusterName: 'some-cluster-1',
pod: {
metadata: {
name: 'ok-pod',
},
spec: {
containers: [
{
name: 'some-container',
},
],
},
status: {
podIP: '127.0.0.1',
containerStatuses: [
{
name: 'some-container',
},
],
},
},
errors: [],
},
} as any)}
/>,
),
);
expect(getAllByText('ok-pod')).toHaveLength(2);
expect(getByText('Pod (127.0.0.1)')).toBeInTheDocument();
expect(getByText('YAML')).toBeInTheDocument();
expect(getByText('Containers')).toBeInTheDocument();
expect(getByText('some-container')).toBeInTheDocument();
});
});
@@ -0,0 +1,124 @@
/*
* 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';
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);
}
interface PodDrawerProps {
open?: boolean;
podAndErrors: PodAndErrors;
}
export const PodDrawer = ({ podAndErrors, open }: PodDrawerProps) => {
const classes = useDrawerContentStyles();
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}>
{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,
);
return (
<ContainerCard
key={`container-card-${podAndErrors.pod.metadata?.name}-${i}`}
podScope={{
podName: podAndErrors.pod.metadata?.name ?? 'unknown',
podNamespace:
podAndErrors.pod.metadata?.namespace ?? 'unknown',
clusterName: podAndErrors.clusterName,
}}
containerSpec={containerSpec}
containerStatus={containerStatus}
/>
);
},
)}
</ItemCardGrid>
</Grid>
</Grid>
)}
</div>
</KubernetesDrawer>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { PodDrawer } from './PodDrawer';
@@ -0,0 +1,55 @@
/*
* 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, LogViewer } from '@backstage/core-components';
import { Paper } from '@material-ui/core';
import { Skeleton } from '@material-ui/lab';
import { ContainerScope } from './types';
import { usePodLogs } from './usePodLogs';
interface PodLogsProps {
podScope: ContainerScope;
}
export const PodLogs: React.FC<PodLogsProps> = ({ podScope }: PodLogsProps) => {
const { value, error, loading } = usePodLogs({
podScope: podScope,
});
return (
<>
{error && (
<DismissableBanner
{...{
message: error.message,
variant: 'error',
fixed: false,
}}
id="pod-logs"
/>
)}
<Paper
elevation={1}
style={{ height: '100%', width: '100%', minHeight: '30rem' }}
>
{loading && <Skeleton variant="rect" width="100%" height="100%" />}
{!loading && value !== undefined && <LogViewer text={value.text} />}
</Paper>
</>
);
};
@@ -0,0 +1,90 @@
/*
* 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],
},
}),
);
interface PodLogsDialogProps {
podScope: ContainerScope;
}
export const PodLogsDialog = ({ podScope }: 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">
{podScope.podName} - {podScope.containerName} logs on cluster{' '}
{podScope.clusterName}
<IconButton
aria-label="close"
className={classes.closeButton}
onClick={closeDialog}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent>
<PodLogs podScope={podScope} />
</DialogContent>
</Dialog>
<Button
variant="outlined"
aria-label="get logs"
component="label"
onClick={openDialog}
startIcon={<SubjectIcon />}
>
Logs
</Button>
</>
);
};
+19
View File
@@ -0,0 +1,19 @@
/*
* 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';
+25
View File
@@ -0,0 +1,25 @@
/*
* 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 interface PodScope {
podName: string;
podNamespace: string;
clusterName: string;
}
export interface ContainerScope extends PodScope {
containerName: string;
}
@@ -0,0 +1,36 @@
/*
* 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 { kubernetesProxyApiRef } from '@backstage/plugin-kubernetes';
import useAsync from 'react-use/lib/useAsync';
import { ContainerScope } from './types';
import { useApi } from '@backstage/core-plugin-api';
interface PodLogsOptions {
podScope: ContainerScope;
}
export const usePodLogs = ({ podScope }: PodLogsOptions) => {
const kubernetesProxyApi = useApi(kubernetesProxyApiRef);
return useAsync(async () => {
return await kubernetesProxyApi.getPodLogs({
podName: podScope.podName,
namespace: podScope.podNamespace,
containerName: podScope.containerName,
clusterName: podScope.clusterName,
});
}, [JSON.stringify(podScope)]);
};
+27 -18
View File
@@ -26,6 +26,7 @@ import {
} from '../../utils/pod';
import { Table, TableColumn } from '@backstage/core-components';
import { PodNamesWithMetricsContext } from '../../hooks/PodNamesWithMetrics';
import { ClusterContext } from '../../hooks/Cluster';
export const READY_COLUMNS: PodColumns = 'READY';
export const RESOURCE_COLUMNS: PodColumns = 'RESOURCE';
@@ -37,23 +38,6 @@ type PodsTablesProps = {
children?: React.ReactNode;
};
const DEFAULT_COLUMNS: TableColumn<V1Pod>[] = [
{
title: 'name',
highlight: true,
render: (pod: V1Pod) => <PodDrawer pod={pod} />,
},
{
title: 'phase',
render: (pod: V1Pod) => pod.status?.phase ?? 'unknown',
width: 'auto',
},
{
title: 'status',
render: containerStatuses,
},
];
const READY: TableColumn<V1Pod>[] = [
{
title: 'containers ready',
@@ -72,7 +56,32 @@ const READY: TableColumn<V1Pod>[] = [
export const PodsTable = ({ pods, extraColumns = [] }: PodsTablesProps) => {
const podNamesWithMetrics = useContext(PodNamesWithMetricsContext);
const columns: TableColumn<V1Pod>[] = [...DEFAULT_COLUMNS];
const cluster = useContext(ClusterContext);
const defaultColumns: TableColumn<V1Pod>[] = [
{
title: 'name',
highlight: true,
render: (pod: V1Pod) => (
<PodDrawer
podAndErrors={{
pod: pod as any,
clusterName: cluster.name,
errors: [],
}}
/>
),
},
{
title: 'phase',
render: (pod: V1Pod) => pod.status?.phase ?? 'unknown',
width: 'auto',
},
{
title: 'status',
render: containerStatuses,
},
];
const columns: TableColumn<V1Pod>[] = [...defaultColumns];
if (extraColumns.includes(READY_COLUMNS)) {
columns.push(...READY);
+22
View File
@@ -0,0 +1,22 @@
/*
* 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 { Pod } from 'kubernetes-models/v1';
export interface PodAndErrors {
clusterName: string;
pod: Pod;
errors: any[];
}
@@ -16,7 +16,7 @@
import React from 'react';
import { V1Service } from '@kubernetes/client-node';
import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer';
import { KubernetesStructuredMetadataTableDrawer } from '../KubernetesDrawer';
import { Typography, Grid } from '@material-ui/core';
export const ServiceDrawer = ({
@@ -27,7 +27,7 @@ export const ServiceDrawer = ({
expanded?: boolean;
}) => {
return (
<KubernetesDrawer
<KubernetesStructuredMetadataTableDrawer
object={service}
expanded={expanded}
kind="Service"
@@ -53,6 +53,6 @@ export const ServiceDrawer = ({
</Typography>
</Grid>
</Grid>
</KubernetesDrawer>
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -16,7 +16,7 @@
import React from 'react';
import { V1StatefulSet } from '@kubernetes/client-node';
import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer';
import { KubernetesStructuredMetadataTableDrawer } from '../KubernetesDrawer';
import { renderCondition } from '../../utils/pod';
import { Typography, Grid, Chip } from '@material-ui/core';
@@ -29,7 +29,7 @@ export const StatefulSetDrawer = ({
}) => {
const namespace = statefulset.metadata?.namespace;
return (
<KubernetesDrawer
<KubernetesStructuredMetadataTableDrawer
object={statefulset}
expanded={expanded}
kind="StatefulSet"
@@ -74,6 +74,6 @@ export const StatefulSetDrawer = ({
</Grid>
)}
</Grid>
</KubernetesDrawer>
</KubernetesStructuredMetadataTableDrawer>
);
};
+1 -1
View File
@@ -22,7 +22,7 @@ export * from './ErrorReporting';
export * from './HorizontalPodAutoscalers';
export * from './IngressesAccordions';
export * from './JobsAccordions';
export { KubernetesDrawer } from './KubernetesDrawer';
export * from './KubernetesDrawer';
export * from './Pods';
export * from './ServicesAccordions';
export * from './KubernetesContent';
+12 -1
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { KubernetesBackendClient } from './api/KubernetesBackendClient';
import { kubernetesApiRef } from './api/types';
import { kubernetesApiRef, kubernetesProxyApiRef } from './api/types';
import { kubernetesAuthProvidersApiRef } from './kubernetes-auth-provider/types';
import { KubernetesAuthProviders } from './kubernetes-auth-provider/KubernetesAuthProviders';
import {
@@ -30,6 +30,7 @@ import {
oneloginAuthApiRef,
createRoutableExtension,
} from '@backstage/core-plugin-api';
import { KubernetesProxyClient } from './api';
export const rootCatalogKubernetesRouteRef = createRouteRef({
id: 'kubernetes',
@@ -52,6 +53,16 @@ export const kubernetesPlugin = createPlugin({
kubernetesAuthProvidersApi,
}),
}),
createApiFactory({
api: kubernetesProxyApiRef,
deps: {
kubernetesApi: kubernetesApiRef,
},
factory: ({ kubernetesApi }) =>
new KubernetesProxyClient({
kubernetesApi,
}),
}),
createApiFactory({
api: kubernetesAuthProvidersApiRef,
deps: {