feat(kubernetes): provide access to the Kubernetes dashboard when viewing a specific resource

Signed-off-by: Morgan Martinet <morgan@mmm-experts.com>
This commit is contained in:
Morgan Martinet
2021-08-10 15:15:45 -04:00
committed by Morgan Martinet
parent 9b5f0dd741
commit 7a0c334707
12 changed files with 254 additions and 31 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-kubernetes': minor
'@backstage/plugin-kubernetes-backend': minor
'@backstage/plugin-kubernetes-common': minor
---
Provide access to the Kubernetes dashboard when viewing a specific resource
@@ -33,6 +33,7 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier {
const clusterDetails = {
name: c.getString('name'),
url: c.getString('url'),
dashboardUrl: c.getOptionalString('dashboardUrl'),
serviceAccountToken: c.getOptionalString('serviceAccountToken'),
skipTLSVerify: c.getOptionalBoolean('skipTLSVerify') ?? false,
authProvider: authProvider,
@@ -114,6 +114,7 @@ export class KubernetesFanOutHandler {
return {
cluster: {
name: clusterDetailsItem.name,
dashboardUrl: clusterDetailsItem.dashboardUrl,
},
resources: result.responses,
errors: result.errors,
@@ -86,6 +86,7 @@ export const makeRouter = (
res.json({
items: clusterDetails.map(cd => ({
name: cd.name,
dashboardUrl: cd.dashboardUrl,
authProvider: cd.authProvider,
})),
});
@@ -80,6 +80,7 @@ export interface ClusterDetails {
authProvider: string;
serviceAccountToken?: string | undefined;
skipTLSVerify?: boolean;
dashboardUrl?: string;
}
export interface GKEClusterDetails extends ClusterDetails {}
+6 -1
View File
@@ -32,8 +32,13 @@ export interface KubernetesRequestBody {
entity: Entity;
}
export interface ClusterAttributes {
name: string;
dashboardUrl?: string;
}
export interface ClusterObjects {
cluster: { name: string };
cluster: ClusterAttributes;
resources: FetchResponse[];
errors: KubernetesFetchError[];
}
@@ -36,6 +36,7 @@ import { ServicesAccordions } from '../ServicesAccordions';
import { CustomResources } from '../CustomResources';
import EmptyStateImage from '../../assets/emptystate.svg';
import {
ClusterContext,
GroupedResponsesContext,
PodNamesWithErrorsContext,
useKubernetesObjects,
@@ -119,35 +120,37 @@ type ClusterProps = {
const Cluster = ({ clusterObjects, podsWithErrors }: ClusterProps) => {
const groupedResponses = groupResponses(clusterObjects.resources);
return (
<GroupedResponsesContext.Provider value={groupedResponses}>
<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">
<Grid item>
<CustomResources />
<ClusterContext.Provider value={clusterObjects.cluster}>
<GroupedResponsesContext.Provider value={groupedResponses}>
<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">
<Grid item>
<CustomResources />
</Grid>
<Grid item>
<DeploymentsAccordions />
</Grid>
<Grid item>
<IngressesAccordions />
</Grid>
<Grid item>
<ServicesAccordions />
</Grid>
</Grid>
<Grid item>
<DeploymentsAccordions />
</Grid>
<Grid item>
<IngressesAccordions />
</Grid>
<Grid item>
<ServicesAccordions />
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
</PodNamesWithErrorsContext.Provider>
</GroupedResponsesContext.Provider>
</AccordionDetails>
</Accordion>
</PodNamesWithErrorsContext.Provider>
</GroupedResponsesContext.Provider>
</ClusterContext.Provider>
);
};
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { ChangeEvent, useState } from 'react';
import React, { ChangeEvent, useContext, useState } from 'react';
import {
Button,
Typography,
@@ -35,6 +35,8 @@ import {
CodeSnippet,
StructuredMetadataTable,
} from '@backstage/core-components';
import { ClusterContext } from '../../hooks';
import { formatClusterLink } from '../../utils/clusterLinks';
const useDrawerStyles = makeStyles((theme: Theme) =>
createStyles({
@@ -56,7 +58,7 @@ const useDrawerContentStyles = makeStyles((_: Theme) =>
options: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-end',
justifyContent: 'space-between',
},
icon: {
fontSize: 20,
@@ -105,6 +107,12 @@ const KubernetesDrawerContent = <T extends KubernetesDrawerable>({
const [isYaml, setIsYaml] = useState<boolean>(false);
const classes = useDrawerContentStyles();
const cluster = useContext(ClusterContext);
const clusterLink = formatClusterLink(
cluster.dashboardUrl ?? '',
object,
kind,
);
return (
<>
@@ -136,6 +144,19 @@ const KubernetesDrawerContent = <T extends KubernetesDrawerable>({
</IconButton>
</div>
<div className={classes.options}>
<div>
{clusterLink && (
<Button
variant="contained"
color="primary"
size="small"
href={clusterLink}
target="_blank"
>
Open Kubernetes Dashboard...
</Button>
)}
</div>
<FormControlLabel
control={
<Switch
+21
View File
@@ -0,0 +1,21 @@
/*
* 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 { ClusterAttributes } from '@backstage/plugin-kubernetes-common';
export const ClusterContext = React.createContext<ClusterAttributes>({
name: '',
});
+1
View File
@@ -17,3 +17,4 @@
export * from './useKubernetesObjects';
export * from './PodNamesWithErrors';
export * from './GroupedResponses';
export * from './Cluster';
@@ -0,0 +1,115 @@
/*
* 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 { formatClusterLink } from './clusterLinks';
describe('clusterLinks', () => {
describe('formatClusterLink', () => {
it('should not return an url when there is no dashboard url', () => {
const url = formatClusterLink('', {}, 'foo');
expect(url).toBeUndefined();
});
it('should return an url even when there is no object', () => {
const url = formatClusterLink('https://k8s.foo.com', undefined, 'foo');
expect(url).toBe('https://k8s.foo.com');
});
it('should return an url on the workloads when there is a namespace only', () => {
const url = formatClusterLink(
'https://k8s.foo.com',
{
metadata: {
namespace: 'bar',
},
},
'foo',
);
expect(url).toBe('https://k8s.foo.com/#/workloads?namespace=bar');
});
it('should return an url on the workloads when the kind is not recognizeed', () => {
const url = formatClusterLink(
'https://k8s.foo.com',
{
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
'UnknownKind',
);
expect(url).toBe('https://k8s.foo.com/#/workloads?namespace=bar');
});
it('should return an url on the deployment', () => {
const url = formatClusterLink(
'https://k8s.foo.com/',
{
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
'Deployment',
);
expect(url).toBe(
'https://k8s.foo.com/#/deployment/bar/foobar?namespace=bar',
);
});
it('should return an url on the service', () => {
const url = formatClusterLink(
'https://k8s.foo.com/',
{
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
'Service',
);
expect(url).toBe(
'https://k8s.foo.com/#/service/bar/foobar?namespace=bar',
);
});
it('should return an url on the ingress', () => {
const url = formatClusterLink(
'https://k8s.foo.com/',
{
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
'Ingress',
);
expect(url).toBe(
'https://k8s.foo.com/#/ingress/bar/foobar?namespace=bar',
);
});
it('should return an url on the deployment for a hpa', () => {
const url = formatClusterLink(
'https://k8s.foo.com/',
{
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
'HorizontalPodAutoscaler',
);
expect(url).toBe(
'https://k8s.foo.com/#/deployment/bar/foobar?namespace=bar',
);
});
});
});
@@ -0,0 +1,46 @@
/*
* 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.
*/
const KindMappings: any = {
deployment: 'deployment',
ingress: 'ingress',
service: 'service',
horizontalpodautoscaler: 'deployment',
};
export function formatClusterLink(
dashboardUrl: string,
object: any,
kind: string,
) {
if (!dashboardUrl) {
return undefined;
}
if (!object) {
return dashboardUrl;
}
const host = dashboardUrl.endsWith('/') ? dashboardUrl : `${dashboardUrl}/`;
const name = object.metadata?.name;
const namespace = object.metadata?.namespace;
const validKind = KindMappings[kind.toLocaleLowerCase()];
if (validKind && name && namespace) {
return `${host}#/${validKind}/${namespace}/${name}?namespace=${namespace}`;
}
if (namespace) {
return `${host}#/workloads?namespace=${namespace}`;
}
return dashboardUrl;
}