diff --git a/.changeset/tiny-berries-battle.md b/.changeset/tiny-berries-battle.md new file mode 100644 index 0000000000..9e3194a317 --- /dev/null +++ b/.changeset/tiny-berries-battle.md @@ -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 diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts index 8352db6754..a946871e7b 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts @@ -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, diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts index fb1876733d..1777d4592a 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -114,6 +114,7 @@ export class KubernetesFanOutHandler { return { cluster: { name: clusterDetailsItem.name, + dashboardUrl: clusterDetailsItem.dashboardUrl, }, resources: result.responses, errors: result.errors, diff --git a/plugins/kubernetes-backend/src/service/router.ts b/plugins/kubernetes-backend/src/service/router.ts index 4792ae708f..d8f7c787d9 100644 --- a/plugins/kubernetes-backend/src/service/router.ts +++ b/plugins/kubernetes-backend/src/service/router.ts @@ -86,6 +86,7 @@ export const makeRouter = ( res.json({ items: clusterDetails.map(cd => ({ name: cd.name, + dashboardUrl: cd.dashboardUrl, authProvider: cd.authProvider, })), }); diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index f4bb019108..f6c3f1e3ab 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -80,6 +80,7 @@ export interface ClusterDetails { authProvider: string; serviceAccountToken?: string | undefined; skipTLSVerify?: boolean; + dashboardUrl?: string; } export interface GKEClusterDetails extends ClusterDetails {} diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index c94a71a184..1da1390207 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -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[]; } diff --git a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx index c0dd6d5003..260c77ed9e 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx @@ -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 ( - - - - }> - - - - - - + + + + + }> + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + ); }; diff --git a/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx index bf714530ce..1fc2f297d3 100644 --- a/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx +++ b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx @@ -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 = ({ const [isYaml, setIsYaml] = useState(false); const classes = useDrawerContentStyles(); + const cluster = useContext(ClusterContext); + const clusterLink = formatClusterLink( + cluster.dashboardUrl ?? '', + object, + kind, + ); return ( <> @@ -136,6 +144,19 @@ const KubernetesDrawerContent = ({
+
+ {clusterLink && ( + + )} +
({ + name: '', +}); diff --git a/plugins/kubernetes/src/hooks/index.ts b/plugins/kubernetes/src/hooks/index.ts index e25903ad3a..88db2d8197 100644 --- a/plugins/kubernetes/src/hooks/index.ts +++ b/plugins/kubernetes/src/hooks/index.ts @@ -17,3 +17,4 @@ export * from './useKubernetesObjects'; export * from './PodNamesWithErrors'; export * from './GroupedResponses'; +export * from './Cluster'; diff --git a/plugins/kubernetes/src/utils/clusterLinks.test.ts b/plugins/kubernetes/src/utils/clusterLinks.test.ts new file mode 100644 index 0000000000..d363b35b3c --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks.test.ts @@ -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', + ); + }); + }); +}); diff --git a/plugins/kubernetes/src/utils/clusterLinks.ts b/plugins/kubernetes/src/utils/clusterLinks.ts new file mode 100644 index 0000000000..342c86956f --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks.ts @@ -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; +}