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:
committed by
Morgan Martinet
parent
9b5f0dd741
commit
7a0c334707
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '',
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user