From 7a0c3347070805f535fc71feb412715b3a790590 Mon Sep 17 00:00:00 2001 From: Morgan Martinet Date: Tue, 10 Aug 2021 15:15:45 -0400 Subject: [PATCH 01/45] feat(kubernetes): provide access to the Kubernetes dashboard when viewing a specific resource Signed-off-by: Morgan Martinet --- .changeset/tiny-berries-battle.md | 7 ++ .../cluster-locator/ConfigClusterLocator.ts | 1 + .../src/service/KubernetesFanOutHandler.ts | 1 + .../kubernetes-backend/src/service/router.ts | 1 + plugins/kubernetes-backend/src/types/types.ts | 1 + plugins/kubernetes-common/src/types.ts | 7 +- .../KubernetesContent/KubernetesContent.tsx | 59 ++++----- .../KubernetesDrawer/KubernetesDrawer.tsx | 25 +++- plugins/kubernetes/src/hooks/Cluster.ts | 21 ++++ plugins/kubernetes/src/hooks/index.ts | 1 + .../kubernetes/src/utils/clusterLinks.test.ts | 115 ++++++++++++++++++ plugins/kubernetes/src/utils/clusterLinks.ts | 46 +++++++ 12 files changed, 254 insertions(+), 31 deletions(-) create mode 100644 .changeset/tiny-berries-battle.md create mode 100644 plugins/kubernetes/src/hooks/Cluster.ts create mode 100644 plugins/kubernetes/src/utils/clusterLinks.test.ts create mode 100644 plugins/kubernetes/src/utils/clusterLinks.ts 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; +} From 891d2bec18b6344051a498edd2208596f040f2ef Mon Sep 17 00:00:00 2001 From: Morgan Martinet Date: Tue, 10 Aug 2021 16:23:38 -0400 Subject: [PATCH 02/45] fix failing unit tests in kubernetes-backend Signed-off-by: Morgan Martinet --- .../src/cluster-locator/ConfigClusterLocator.test.ts | 6 ++++++ .../kubernetes-backend/src/cluster-locator/index.test.ts | 2 ++ .../src/service/KubernetesFanOutHandler.test.ts | 9 +++++++++ 3 files changed, 17 insertions(+) diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts index dac96e4cef..cd31db807e 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts @@ -49,6 +49,7 @@ describe('ConfigClusterLocator', () => { expect(result).toStrictEqual([ { name: 'cluster1', + dashboardUrl: undefined, serviceAccountToken: undefined, url: 'http://localhost:8080', authProvider: 'serviceAccount', @@ -66,6 +67,7 @@ describe('ConfigClusterLocator', () => { url: 'http://localhost:8080', authProvider: 'serviceAccount', skipTLSVerify: false, + dashboardUrl: 'https://k8s.foo.com', }, { name: 'cluster2', @@ -83,6 +85,7 @@ describe('ConfigClusterLocator', () => { expect(result).toStrictEqual([ { name: 'cluster1', + dashboardUrl: 'https://k8s.foo.com', serviceAccountToken: 'token', url: 'http://localhost:8080', authProvider: 'serviceAccount', @@ -90,6 +93,7 @@ describe('ConfigClusterLocator', () => { }, { name: 'cluster2', + dashboardUrl: undefined, serviceAccountToken: undefined, url: 'http://localhost:8081', authProvider: 'google', @@ -133,6 +137,7 @@ describe('ConfigClusterLocator', () => { expect(result).toStrictEqual([ { assumeRole: undefined, + dashboardUrl: undefined, name: 'cluster1', serviceAccountToken: 'token', externalId: undefined, @@ -142,6 +147,7 @@ describe('ConfigClusterLocator', () => { }, { assumeRole: 'SomeRole', + dashboardUrl: undefined, name: 'cluster2', externalId: undefined, serviceAccountToken: undefined, diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts index 95be99a8a5..9b01a4b0f3 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts @@ -50,6 +50,7 @@ describe('getCombinedClusterDetails', () => { expect(result).toStrictEqual([ { name: 'cluster1', + dashboardUrl: undefined, serviceAccountToken: 'token', url: 'http://localhost:8080', authProvider: 'serviceAccount', @@ -57,6 +58,7 @@ describe('getCombinedClusterDetails', () => { }, { name: 'cluster2', + dashboardUrl: undefined, serviceAccountToken: undefined, url: 'http://localhost:8081', authProvider: 'google', diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts index a867efaaee..28e8f45603 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts @@ -165,6 +165,7 @@ describe('handleGetKubernetesObjectsForService', () => { items: [ { cluster: { + dashboardUrl: undefined, name: 'test-cluster', }, errors: [], @@ -211,6 +212,7 @@ describe('handleGetKubernetesObjectsForService', () => { { name: 'test-cluster', authProvider: 'serviceAccount', + dashboardUrl: 'https://k8s.foo.coom', }, { name: 'other-cluster', @@ -260,6 +262,7 @@ describe('handleGetKubernetesObjectsForService', () => { items: [ { cluster: { + dashboardUrl: 'https://k8s.foo.coom', name: 'test-cluster', }, errors: [], @@ -298,6 +301,7 @@ describe('handleGetKubernetesObjectsForService', () => { }, { cluster: { + dashboardUrl: undefined, name: 'other-cluster', }, errors: [], @@ -396,6 +400,7 @@ describe('handleGetKubernetesObjectsForService', () => { items: [ { cluster: { + dashboardUrl: undefined, name: 'test-cluster', }, errors: [], @@ -434,6 +439,7 @@ describe('handleGetKubernetesObjectsForService', () => { }, { cluster: { + dashboardUrl: undefined, name: 'other-cluster', }, errors: [], @@ -536,6 +542,7 @@ describe('handleGetKubernetesObjectsForService', () => { items: [ { cluster: { + dashboardUrl: undefined, name: 'test-cluster', }, errors: [], @@ -574,6 +581,7 @@ describe('handleGetKubernetesObjectsForService', () => { }, { cluster: { + dashboardUrl: undefined, name: 'other-cluster', }, errors: [], @@ -612,6 +620,7 @@ describe('handleGetKubernetesObjectsForService', () => { }, { cluster: { + dashboardUrl: undefined, name: 'error-cluster', }, errors: ['some random cluster error'], From c80f53a4b543e1e5c4cef7798095fe6e85cf79e2 Mon Sep 17 00:00:00 2001 From: Morgan Martinet Date: Fri, 13 Aug 2021 10:35:08 -0400 Subject: [PATCH 03/45] minor refactorings after code review Signed-off-by: Morgan Martinet --- .changeset/tiny-berries-battle.md | 6 +- .../ConfigClusterLocator.test.ts | 4 -- .../cluster-locator/ConfigClusterLocator.ts | 7 +- .../src/cluster-locator/index.test.ts | 2 - .../service/KubernetesFanOutHandler.test.ts | 7 -- .../src/service/KubernetesFanOutHandler.ts | 12 +++- .../KubernetesDrawer/KubernetesDrawer.tsx | 11 +-- .../kubernetes/src/utils/clusterLinks.test.ts | 68 ++++++++++--------- plugins/kubernetes/src/utils/clusterLinks.ts | 36 +++++----- 9 files changed, 79 insertions(+), 74 deletions(-) diff --git a/.changeset/tiny-berries-battle.md b/.changeset/tiny-berries-battle.md index 9e3194a317..fdcb612d61 100644 --- a/.changeset/tiny-berries-battle.md +++ b/.changeset/tiny-berries-battle.md @@ -1,7 +1,7 @@ --- -'@backstage/plugin-kubernetes': minor -'@backstage/plugin-kubernetes-backend': minor -'@backstage/plugin-kubernetes-common': minor +'@backstage/plugin-kubernetes': patch +'@backstage/plugin-kubernetes-backend': patch +'@backstage/plugin-kubernetes-common': patch --- Provide access to the Kubernetes dashboard when viewing a specific resource diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts index cd31db807e..6d1506c4c7 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts @@ -49,7 +49,6 @@ describe('ConfigClusterLocator', () => { expect(result).toStrictEqual([ { name: 'cluster1', - dashboardUrl: undefined, serviceAccountToken: undefined, url: 'http://localhost:8080', authProvider: 'serviceAccount', @@ -93,7 +92,6 @@ describe('ConfigClusterLocator', () => { }, { name: 'cluster2', - dashboardUrl: undefined, serviceAccountToken: undefined, url: 'http://localhost:8081', authProvider: 'google', @@ -137,7 +135,6 @@ describe('ConfigClusterLocator', () => { expect(result).toStrictEqual([ { assumeRole: undefined, - dashboardUrl: undefined, name: 'cluster1', serviceAccountToken: 'token', externalId: undefined, @@ -147,7 +144,6 @@ describe('ConfigClusterLocator', () => { }, { assumeRole: 'SomeRole', - dashboardUrl: undefined, name: 'cluster2', externalId: undefined, serviceAccountToken: undefined, diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts index a946871e7b..49ebeaade1 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts @@ -30,14 +30,17 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier { return new ConfigClusterLocator( config.getConfigArray('clusters').map(c => { const authProvider = c.getString('authProvider'); - const clusterDetails = { + const clusterDetails: ClusterDetails = { name: c.getString('name'), url: c.getString('url'), - dashboardUrl: c.getOptionalString('dashboardUrl'), serviceAccountToken: c.getOptionalString('serviceAccountToken'), skipTLSVerify: c.getOptionalBoolean('skipTLSVerify') ?? false, authProvider: authProvider, }; + const dashboardUrl = c.getOptionalString('dashboardUrl'); + if (dashboardUrl) { + clusterDetails.dashboardUrl = dashboardUrl; + } switch (authProvider) { case 'google': { diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts index 9b01a4b0f3..95be99a8a5 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts @@ -50,7 +50,6 @@ describe('getCombinedClusterDetails', () => { expect(result).toStrictEqual([ { name: 'cluster1', - dashboardUrl: undefined, serviceAccountToken: 'token', url: 'http://localhost:8080', authProvider: 'serviceAccount', @@ -58,7 +57,6 @@ describe('getCombinedClusterDetails', () => { }, { name: 'cluster2', - dashboardUrl: undefined, serviceAccountToken: undefined, url: 'http://localhost:8081', authProvider: 'google', diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts index 28e8f45603..7973448467 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts @@ -165,7 +165,6 @@ describe('handleGetKubernetesObjectsForService', () => { items: [ { cluster: { - dashboardUrl: undefined, name: 'test-cluster', }, errors: [], @@ -301,7 +300,6 @@ describe('handleGetKubernetesObjectsForService', () => { }, { cluster: { - dashboardUrl: undefined, name: 'other-cluster', }, errors: [], @@ -400,7 +398,6 @@ describe('handleGetKubernetesObjectsForService', () => { items: [ { cluster: { - dashboardUrl: undefined, name: 'test-cluster', }, errors: [], @@ -439,7 +436,6 @@ describe('handleGetKubernetesObjectsForService', () => { }, { cluster: { - dashboardUrl: undefined, name: 'other-cluster', }, errors: [], @@ -542,7 +538,6 @@ describe('handleGetKubernetesObjectsForService', () => { items: [ { cluster: { - dashboardUrl: undefined, name: 'test-cluster', }, errors: [], @@ -581,7 +576,6 @@ describe('handleGetKubernetesObjectsForService', () => { }, { cluster: { - dashboardUrl: undefined, name: 'other-cluster', }, errors: [], @@ -620,7 +614,6 @@ describe('handleGetKubernetesObjectsForService', () => { }, { cluster: { - dashboardUrl: undefined, name: 'error-cluster', }, errors: ['some random cluster error'], diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts index 1777d4592a..a1b6f90040 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -22,7 +22,10 @@ import { KubernetesObjectTypes, KubernetesServiceLocator, } from '../types/types'; -import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; +import { + ClusterObjects, + KubernetesRequestBody, +} from '@backstage/plugin-kubernetes-common'; import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types'; import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator/KubernetesAuthTranslatorGenerator'; @@ -111,14 +114,17 @@ export class KubernetesFanOutHandler { customResources: this.customResources, }) .then(result => { - return { + const objects: ClusterObjects = { cluster: { name: clusterDetailsItem.name, - dashboardUrl: clusterDetailsItem.dashboardUrl, }, resources: result.responses, errors: result.errors, }; + if (clusterDetailsItem.dashboardUrl) { + objects.cluster.dashboardUrl = clusterDetailsItem.dashboardUrl; + } + return objects; }); }), ).then(r => ({ diff --git a/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx index 1fc2f297d3..050d7222d8 100644 --- a/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx +++ b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx @@ -34,6 +34,7 @@ import jsYaml from 'js-yaml'; import { CodeSnippet, StructuredMetadataTable, + Link, } from '@backstage/core-components'; import { ClusterContext } from '../../hooks'; import { formatClusterLink } from '../../utils/clusterLinks'; @@ -108,11 +109,11 @@ const KubernetesDrawerContent = ({ const classes = useDrawerContentStyles(); const cluster = useContext(ClusterContext); - const clusterLink = formatClusterLink( - cluster.dashboardUrl ?? '', + const clusterLink = formatClusterLink({ + dashboardUrl: cluster.dashboardUrl, object, kind, - ); + }); return ( <> @@ -150,8 +151,8 @@ const KubernetesDrawerContent = ({ variant="contained" color="primary" size="small" - href={clusterLink} - target="_blank" + component={Link} + to={clusterLink} > Open Kubernetes Dashboard... diff --git a/plugins/kubernetes/src/utils/clusterLinks.test.ts b/plugins/kubernetes/src/utils/clusterLinks.test.ts index d363b35b3c..7c649a34b9 100644 --- a/plugins/kubernetes/src/utils/clusterLinks.test.ts +++ b/plugins/kubernetes/src/utils/clusterLinks.test.ts @@ -19,94 +19,98 @@ import { formatClusterLink } from './clusterLinks'; describe('clusterLinks', () => { describe('formatClusterLink', () => { it('should not return an url when there is no dashboard url', () => { - const url = formatClusterLink('', {}, 'foo'); + const url = formatClusterLink({ object: {}, kind: 'foo' }); expect(url).toBeUndefined(); }); it('should return an url even when there is no object', () => { - const url = formatClusterLink('https://k8s.foo.com', undefined, 'foo'); + const url = formatClusterLink({ + dashboardUrl: 'https://k8s.foo.com', + object: undefined, + kind: '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', - { + const url = formatClusterLink({ + dashboardUrl: 'https://k8s.foo.com', + object: { metadata: { namespace: 'bar', }, }, - 'foo', - ); + kind: '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', - { + const url = formatClusterLink({ + dashboardUrl: 'https://k8s.foo.com', + object: { metadata: { name: 'foobar', namespace: 'bar', }, }, - 'UnknownKind', - ); + kind: '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/', - { + const url = formatClusterLink({ + dashboardUrl: 'https://k8s.foo.com/', + object: { metadata: { name: 'foobar', namespace: 'bar', }, }, - 'Deployment', - ); + kind: '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/', - { + const url = formatClusterLink({ + dashboardUrl: 'https://k8s.foo.com/', + object: { metadata: { name: 'foobar', namespace: 'bar', }, }, - 'Service', - ); + kind: '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/', - { + const url = formatClusterLink({ + dashboardUrl: 'https://k8s.foo.com/', + object: { metadata: { name: 'foobar', namespace: 'bar', }, }, - 'Ingress', - ); + kind: '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/', - { + const url = formatClusterLink({ + dashboardUrl: 'https://k8s.foo.com/', + object: { metadata: { name: 'foobar', namespace: 'bar', }, }, - 'HorizontalPodAutoscaler', - ); + kind: '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 index 342c86956f..53f771a1b6 100644 --- a/plugins/kubernetes/src/utils/clusterLinks.ts +++ b/plugins/kubernetes/src/utils/clusterLinks.ts @@ -14,33 +14,37 @@ * limitations under the License. */ -const KindMappings: any = { +const KindMappings: Record = { deployment: 'deployment', ingress: 'ingress', service: 'service', horizontalpodautoscaler: 'deployment', }; -export function formatClusterLink( - dashboardUrl: string, - object: any, - kind: string, -) { - if (!dashboardUrl) { +export function formatClusterLink(options: { + dashboardUrl?: string; + object: any; + kind: string; +}) { + if (!options.dashboardUrl) { return undefined; } - if (!object) { - return dashboardUrl; + if (!options.object) { + return options.dashboardUrl; } - const host = dashboardUrl.endsWith('/') ? dashboardUrl : `${dashboardUrl}/`; - const name = object.metadata?.name; - const namespace = object.metadata?.namespace; - const validKind = KindMappings[kind.toLocaleLowerCase()]; + const host = options.dashboardUrl.endsWith('/') + ? options.dashboardUrl + : `${options.dashboardUrl}/`; + const name = options.object.metadata?.name; + const namespace = options.object.metadata?.namespace; + const validKind = KindMappings[options.kind.toLocaleLowerCase()]; if (validKind && name && namespace) { - return `${host}#/${validKind}/${namespace}/${name}?namespace=${namespace}`; + return `${host}#/${encodeURIComponent(validKind)}/${encodeURIComponent( + namespace, + )}/${encodeURIComponent(name)}?namespace=${encodeURIComponent(namespace)}`; } if (namespace) { - return `${host}#/workloads?namespace=${namespace}`; + return `${host}#/workloads?namespace=${encodeURIComponent(namespace)}`; } - return dashboardUrl; + return options.dashboardUrl; } From 10f8c37595c40d8fabf889dfa169813edd3e6a21 Mon Sep 17 00:00:00 2001 From: Morgan Martinet Date: Fri, 13 Aug 2021 18:16:21 -0400 Subject: [PATCH 04/45] fix compilation error in KubernetesDrawer Signed-off-by: Morgan Martinet --- .../src/components/KubernetesDrawer/KubernetesDrawer.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx index 050d7222d8..31b87052c5 100644 --- a/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx +++ b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx @@ -32,6 +32,7 @@ import { V1ObjectMeta } from '@kubernetes/client-node'; import { withStyles } from '@material-ui/core/styles'; import jsYaml from 'js-yaml'; import { + Button as BackstageButton, CodeSnippet, StructuredMetadataTable, Link, @@ -147,7 +148,7 @@ const KubernetesDrawerContent = ({
{clusterLink && ( - + )}
Date: Fri, 13 Aug 2021 23:09:43 -0400 Subject: [PATCH 05/45] regen api-report.md and rebase branch on master Signed-off-by: Morgan Martinet --- plugins/kubernetes-backend/api-report.md | 7 +++++-- plugins/kubernetes-common/api-report.md | 14 +++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index f69977f5ad..ee3bea69f9 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -3,10 +3,11 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { ClusterObjects } from '@backstage/plugin-kubernetes-common'; import { Config } from '@backstage/config'; import express from 'express'; -import { FetchResponse } from '@backstage/plugin-kubernetes-common'; -import { KubernetesFetchError } from '@backstage/plugin-kubernetes-common'; +import type { FetchResponse } from '@backstage/plugin-kubernetes-common'; +import type { KubernetesFetchError } from '@backstage/plugin-kubernetes-common'; import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { Logger as Logger_2 } from 'winston'; @@ -27,6 +28,8 @@ export interface ClusterDetails { // (undocumented) authProvider: string; // (undocumented) + dashboardUrl?: string; + // (undocumented) name: string; // (undocumented) serviceAccountToken?: string | undefined; diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index 04f54b1f1a..11ce9d10de 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -17,14 +17,22 @@ import { V1Service } from '@kubernetes/client-node'; // @public (undocumented) export type AuthProviderType = 'google' | 'serviceAccount' | 'aws'; +// Warning: (ae-missing-release-tag) "ClusterAttributes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface ClusterAttributes { + // (undocumented) + dashboardUrl?: string; + // (undocumented) + name: string; +} + // Warning: (ae-missing-release-tag) "ClusterObjects" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface ClusterObjects { // (undocumented) - cluster: { - name: string; - }; + cluster: ClusterAttributes; // (undocumented) errors: KubernetesFetchError[]; // (undocumented) From abf7c46404bcb06156f3630fa67be5271eaa46ae Mon Sep 17 00:00:00 2001 From: Morgan Martinet Date: Sun, 29 Aug 2021 19:07:34 -0400 Subject: [PATCH 06/45] refactor code in order to support multiple dashboard link formatters Signed-off-by: Morgan Martinet --- docs/features/kubernetes/configuration.md | 36 +++++ plugins/kubernetes-backend/api-report.md | 3 +- .../cluster-locator/ConfigClusterLocator.ts | 4 + .../src/service/KubernetesFanOutHandler.ts | 3 + plugins/kubernetes-backend/src/types/types.ts | 27 ++++ plugins/kubernetes-common/api-report.md | 3 +- plugins/kubernetes-common/src/types.ts | 27 ++++ plugins/kubernetes/api-report.md | 16 +++ .../KubernetesDrawer/KubernetesDrawer.tsx | 1 + plugins/kubernetes/src/index.ts | 1 + plugins/kubernetes/src/types/types.ts | 10 ++ .../kubernetes/src/utils/clusterLinks.test.ts | 119 ---------------- .../clusterLinks/formatClusterLink.test.ts | 133 ++++++++++++++++++ .../utils/clusterLinks/formatClusterLink.ts | 45 ++++++ .../utils/clusterLinks/formatters/aks.test.ts | 33 +++++ .../src/utils/clusterLinks/formatters/aks.ts | 20 +++ .../utils/clusterLinks/formatters/eks.test.ts | 33 +++++ .../src/utils/clusterLinks/formatters/eks.ts | 20 +++ .../utils/clusterLinks/formatters/gke.test.ts | 33 +++++ .../src/utils/clusterLinks/formatters/gke.ts | 20 +++ .../utils/clusterLinks/formatters/index.ts | 26 ++++ .../clusterLinks/formatters/openshift.test.ts | 33 +++++ .../clusterLinks/formatters/openshift.ts | 22 +++ .../clusterLinks/formatters/rancher.test.ts | 33 +++++ .../utils/clusterLinks/formatters/rancher.ts | 20 +++ .../clusterLinks/formatters/standard.test.ts | 115 +++++++++++++++ .../formatters/standard.ts} | 31 ++-- .../src/utils/clusterLinks/index.ts | 18 +++ plugins/kubernetes/src/utils/index.ts | 16 +++ 29 files changed, 757 insertions(+), 144 deletions(-) delete mode 100644 plugins/kubernetes/src/utils/clusterLinks.test.ts create mode 100644 plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.test.ts create mode 100644 plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts create mode 100644 plugins/kubernetes/src/utils/clusterLinks/formatters/aks.test.ts create mode 100644 plugins/kubernetes/src/utils/clusterLinks/formatters/aks.ts create mode 100644 plugins/kubernetes/src/utils/clusterLinks/formatters/eks.test.ts create mode 100644 plugins/kubernetes/src/utils/clusterLinks/formatters/eks.ts create mode 100644 plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts create mode 100644 plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts create mode 100644 plugins/kubernetes/src/utils/clusterLinks/formatters/index.ts create mode 100644 plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts create mode 100644 plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.ts create mode 100644 plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.test.ts create mode 100644 plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.ts create mode 100644 plugins/kubernetes/src/utils/clusterLinks/formatters/standard.test.ts rename plugins/kubernetes/src/utils/{clusterLinks.ts => clusterLinks/formatters/standard.ts} (62%) create mode 100644 plugins/kubernetes/src/utils/clusterLinks/index.ts create mode 100644 plugins/kubernetes/src/utils/index.ts diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index 068a17249c..737a753635 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -27,6 +27,8 @@ kubernetes: authProvider: 'serviceAccount' skipTLSVerify: false serviceAccountToken: ${K8S_MINIKUBE_TOKEN} + dashboardUrl: http://127.0.0.1:64713 # url copied from running the command: minikube service kubernetes-dashboard -n kubernetes-dashboard + dashboardApp: standard - url: http://127.0.0.2:9999 name: aws-cluster-1 authProvider: 'aws' @@ -98,6 +100,40 @@ kubectl -n get secret $(kubectl -n get sa ...; +``` + +See also +https://github.com/backstage/backstage/tree/master/plugins/kubernetes/src/utils/clusterLinks/formatters +for real examples. + #### `gke` This cluster locator is designed to work with Kubernetes clusters running in diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index ee3bea69f9..2a52089c49 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -27,9 +27,8 @@ export interface AWSClusterDetails extends ClusterDetails { export interface ClusterDetails { // (undocumented) authProvider: string; - // (undocumented) + dashboardApp?: string; dashboardUrl?: string; - // (undocumented) name: string; // (undocumented) serviceAccountToken?: string | undefined; diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts index 49ebeaade1..53d3a1d025 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts @@ -41,6 +41,10 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier { if (dashboardUrl) { clusterDetails.dashboardUrl = dashboardUrl; } + const dashboardApp = c.getOptionalString('dashboardApp'); + if (dashboardApp) { + clusterDetails.dashboardApp = dashboardApp; + } switch (authProvider) { case 'google': { diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts index a1b6f90040..fd403f49d1 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -124,6 +124,9 @@ export class KubernetesFanOutHandler { if (clusterDetailsItem.dashboardUrl) { objects.cluster.dashboardUrl = clusterDetailsItem.dashboardUrl; } + if (clusterDetailsItem.dashboardApp) { + objects.cluster.dashboardApp = clusterDetailsItem.dashboardApp; + } return objects; }); }), diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index f6c3f1e3ab..fe62d0fd78 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -75,12 +75,39 @@ export interface KubernetesServiceLocator { export type ServiceLocatorMethod = 'multiTenant' | 'http'; // TODO implement http export interface ClusterDetails { + /** + * Specifies the name of the Kubernetes cluster. + */ name: string; url: string; authProvider: string; serviceAccountToken?: string | undefined; skipTLSVerify?: boolean; + /** + * Specifies the link to the Kubernetes dashboard managing this cluster. + * @remarks + * Note that you need to specify the app used for the dashboard + * using the dashboardApp property, in order to properly format + * links to kubernetes resources. + * @see dashboardApp + */ dashboardUrl?: string; + /** + * Specifies the app that provides the Kubernetes dashboard. + * This will be used for formatting links to kubernetes objects inside the dashboard. + * @remarks + * The existing apps are: standard, rancher, openshift, gke, aks, eks + * Note that it will default to the regular dashboard provided by the Kubernetes project (standard). + * Note that you can add your own formatter by registering it to the formatters dictionary. + * @defaultValue standard + * @see dashboardUrl + * @example + * ```ts + * import { clusterLinksFormatters } from '@backstage/plugin-kubernetes'; + * clusterLinksFormatters.myDashboard = (options) => ...; + * ``` + */ + dashboardApp?: string; } export interface GKEClusterDetails extends ClusterDetails {} diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index 11ce9d10de..7981c29b4e 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -21,9 +21,8 @@ export type AuthProviderType = 'google' | 'serviceAccount' | 'aws'; // // @public (undocumented) export interface ClusterAttributes { - // (undocumented) + dashboardApp?: string; dashboardUrl?: string; - // (undocumented) name: string; } diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index 1da1390207..8b8aba9a93 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -33,8 +33,35 @@ export interface KubernetesRequestBody { } export interface ClusterAttributes { + /** + * Specifies the name of the Kubernetes cluster. + */ name: string; + /** + * Specifies the link to the Kubernetes dashboard managing this cluster. + * @remarks + * Note that you need to specify the app used for the dashboard + * using the dashboardApp property, in order to properly format + * links to kubernetes resources. + * @see dashboardApp + */ dashboardUrl?: string; + /** + * Specifies the app that provides the Kubernetes dashboard. + * This will be used for formatting links to kubernetes objects inside the dashboard. + * @remarks + * The supported dashboards are: standard, rancher, openshift, gke, aks, eks + * Note that it will default to the regular dashboard provided by the Kubernetes project (standard). + * Note that you can add your own formatter by registering it to the formatters dictionary. + * @defaultValue standard + * @see dashboardUrl + * @example + * ```ts + * import { clusterLinksFormatters } from '@backstage/plugin-kubernetes'; + * clusterLinksFormatters.myDashboard = (options) => ...; + * ``` + */ + dashboardApp?: string; } export interface ClusterObjects { diff --git a/plugins/kubernetes/api-report.md b/plugins/kubernetes/api-report.md index 0a3b466942..e3c239949b 100644 --- a/plugins/kubernetes/api-report.md +++ b/plugins/kubernetes/api-report.md @@ -12,6 +12,12 @@ import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { OAuthApi } from '@backstage/core-plugin-api'; import { RouteRef } from '@backstage/core-plugin-api'; +// Warning: (ae-forgotten-export) The symbol "ClusterLinksFormatter" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "clusterLinksFormatters" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const clusterLinksFormatters: Record; + // Warning: (ae-missing-release-tag) "EntityKubernetesContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -19,6 +25,16 @@ export const EntityKubernetesContent: (_props: { entity?: Entity | undefined; }) => JSX.Element; +// Warning: (ae-missing-release-tag) "formatClusterLink" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function formatClusterLink(options: { + dashboardUrl?: string; + dashboardApp?: string; + object: any; + kind: string; +}): string | undefined; + // Warning: (ae-forgotten-export) The symbol "KubernetesAuthProvidersApi" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "KubernetesAuthProviders" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx index 31b87052c5..4a51655484 100644 --- a/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx +++ b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx @@ -112,6 +112,7 @@ const KubernetesDrawerContent = ({ const cluster = useContext(ClusterContext); const clusterLink = formatClusterLink({ dashboardUrl: cluster.dashboardUrl, + dashboardApp: cluster.dashboardApp, object, kind, }); diff --git a/plugins/kubernetes/src/index.ts b/plugins/kubernetes/src/index.ts index 23acc3e7ca..4f4c3d03da 100644 --- a/plugins/kubernetes/src/index.ts +++ b/plugins/kubernetes/src/index.ts @@ -20,3 +20,4 @@ export { } from './plugin'; export { Router } from './Router'; export * from './kubernetes-auth-provider'; +export * from './utils/clusterLinks'; diff --git a/plugins/kubernetes/src/types/types.ts b/plugins/kubernetes/src/types/types.ts index 0817c90f97..05337086be 100644 --- a/plugins/kubernetes/src/types/types.ts +++ b/plugins/kubernetes/src/types/types.ts @@ -37,3 +37,13 @@ export interface GroupedResponses extends DeploymentResources { ingresses: ExtensionsV1beta1Ingress[]; customResources: any[]; } + +export interface ClusterLinksFormatterOptions { + dashboardUrl: URL; + object: any; + kind: string; +} + +export type ClusterLinksFormatter = ( + options: ClusterLinksFormatterOptions, +) => URL; diff --git a/plugins/kubernetes/src/utils/clusterLinks.test.ts b/plugins/kubernetes/src/utils/clusterLinks.test.ts deleted file mode 100644 index 7c649a34b9..0000000000 --- a/plugins/kubernetes/src/utils/clusterLinks.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { formatClusterLink } from './clusterLinks'; - -describe('clusterLinks', () => { - describe('formatClusterLink', () => { - it('should not return an url when there is no dashboard url', () => { - const url = formatClusterLink({ object: {}, kind: 'foo' }); - expect(url).toBeUndefined(); - }); - it('should return an url even when there is no object', () => { - const url = formatClusterLink({ - dashboardUrl: 'https://k8s.foo.com', - object: undefined, - kind: '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({ - dashboardUrl: 'https://k8s.foo.com', - object: { - metadata: { - namespace: 'bar', - }, - }, - kind: '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({ - dashboardUrl: 'https://k8s.foo.com', - object: { - metadata: { - name: 'foobar', - namespace: 'bar', - }, - }, - kind: 'UnknownKind', - }); - expect(url).toBe('https://k8s.foo.com/#/workloads?namespace=bar'); - }); - it('should return an url on the deployment', () => { - const url = formatClusterLink({ - dashboardUrl: 'https://k8s.foo.com/', - object: { - metadata: { - name: 'foobar', - namespace: 'bar', - }, - }, - kind: 'Deployment', - }); - expect(url).toBe( - 'https://k8s.foo.com/#/deployment/bar/foobar?namespace=bar', - ); - }); - it('should return an url on the service', () => { - const url = formatClusterLink({ - dashboardUrl: 'https://k8s.foo.com/', - object: { - metadata: { - name: 'foobar', - namespace: 'bar', - }, - }, - kind: 'Service', - }); - expect(url).toBe( - 'https://k8s.foo.com/#/service/bar/foobar?namespace=bar', - ); - }); - it('should return an url on the ingress', () => { - const url = formatClusterLink({ - dashboardUrl: 'https://k8s.foo.com/', - object: { - metadata: { - name: 'foobar', - namespace: 'bar', - }, - }, - kind: '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({ - dashboardUrl: 'https://k8s.foo.com/', - object: { - metadata: { - name: 'foobar', - namespace: 'bar', - }, - }, - kind: 'HorizontalPodAutoscaler', - }); - expect(url).toBe( - 'https://k8s.foo.com/#/deployment/bar/foobar?namespace=bar', - ); - }); - }); -}); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.test.ts new file mode 100644 index 0000000000..afc53d9509 --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.test.ts @@ -0,0 +1,133 @@ +/* + * 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 { formatClusterLink } from './formatClusterLink'; + +describe('clusterLinks', () => { + describe('formatClusterLink', () => { + it('should not return an url when there is no dashboard url', () => { + const url = formatClusterLink({ object: {}, kind: 'foo' }); + expect(url).toBeUndefined(); + }); + it('should return an url even when there is no object', () => { + const url = formatClusterLink({ + dashboardUrl: 'https://k8s.foo.com', + object: undefined, + kind: 'foo', + }); + expect(url).toBe('https://k8s.foo.com'); + }); + it('should throw when the app is not recognized', () => { + expect(() => + formatClusterLink({ + dashboardUrl: 'https://k8s.foo.com', + dashboardApp: 'unknownapp', + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }), + ).toThrowError( + "Could not find Kubernetes dashboard app named 'unknownapp'", + ); + }); + + describe('default app', () => { + it('should return an url on the deployment', () => { + const url = formatClusterLink({ + dashboardUrl: 'https://k8s.foo.com/', + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }); + expect(url).toBe( + 'https://k8s.foo.com/#/deployment/bar/foobar?namespace=bar', + ); + }); + it('should return an url on the service', () => { + const url = formatClusterLink({ + dashboardUrl: 'https://k8s.foo.com/', + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Service', + }); + expect(url).toBe( + 'https://k8s.foo.com/#/service/bar/foobar?namespace=bar', + ); + }); + it('should return an url on the deployment properly url encoded', () => { + const url = formatClusterLink({ + dashboardUrl: 'https://k8s.foo.com/', + object: { + metadata: { + name: 'foobar', + namespace: 'bar bar', + }, + }, + kind: 'Deployment', + }); + expect(url).toBe( + 'https://k8s.foo.com/#/deployment/bar%20bar/foobar?namespace=bar+bar', + ); + }); + }); + + describe('standard app', () => { + it('should return an url on the deployment', () => { + const url = formatClusterLink({ + dashboardUrl: 'https://k8s.foo.com/', + dashboardApp: 'standard', + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }); + expect(url).toBe( + 'https://k8s.foo.com/#/deployment/bar/foobar?namespace=bar', + ); + }); + it('should return an url on the service', () => { + const url = formatClusterLink({ + dashboardUrl: 'https://k8s.foo.com/', + dashboardApp: 'standard', + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Service', + }); + expect(url).toBe( + 'https://k8s.foo.com/#/service/bar/foobar?namespace=bar', + ); + }); + }); + }); +}); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts b/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts new file mode 100644 index 0000000000..f04ec1daea --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts @@ -0,0 +1,45 @@ +/* + * 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 { defaultFormatterName, clusterLinksFormatters } from './formatters'; + +export function formatClusterLink(options: { + dashboardUrl?: string; + dashboardApp?: string; + object: any; + kind: string; +}) { + if (!options.dashboardUrl) { + return undefined; + } + if (!options.object) { + return options.dashboardUrl; + } + const app = options.dashboardApp || defaultFormatterName; + const formatter = clusterLinksFormatters[app]; + if (!formatter) { + throw new Error(`Could not find Kubernetes dashboard app named '${app}'`); + } + const url = formatter({ + dashboardUrl: new URL(options.dashboardUrl), + object: options.object, + kind: options.kind, + }); + // Note that we can't rely on 'url.href' since it will put the search before the hash + // and this won't be properly recognized by SPAs such as Angular in the standard dashboard. + // Note also that pathname, hash and search will be properly url encoded. + return `${url.origin}${url.pathname}${url.hash}${url.search}`; +} diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.test.ts new file mode 100644 index 0000000000..381df0dc28 --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.test.ts @@ -0,0 +1,33 @@ +/* + * 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 { aksFormatter } from './aks'; + +describe('clusterLinks - aks formatter', () => { + it('should return an url on the workloads when there is a namespace only', () => { + expect(() => + aksFormatter({ + dashboardUrl: new URL('https://k8s.foo.com'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }), + ).toThrowError('AKS formatter is not yet implemented'); + }); +}); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.ts new file mode 100644 index 0000000000..ef221b604d --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.ts @@ -0,0 +1,20 @@ +/* + * 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 { ClusterLinksFormatterOptions } from '../../../types/types'; + +export function aksFormatter(_options: ClusterLinksFormatterOptions): URL { + throw new Error('AKS formatter is not yet implemented'); +} diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.test.ts new file mode 100644 index 0000000000..a1a5252bc4 --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.test.ts @@ -0,0 +1,33 @@ +/* + * 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 { eksFormatter } from './eks'; + +describe('clusterLinks - aks formatter', () => { + it('should return an url on the workloads when there is a namespace only', () => { + expect(() => + eksFormatter({ + dashboardUrl: new URL('https://k8s.foo.com'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }), + ).toThrowError('EKS formatter is not yet implemented'); + }); +}); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.ts new file mode 100644 index 0000000000..a9fb2f265b --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.ts @@ -0,0 +1,20 @@ +/* + * 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 { ClusterLinksFormatterOptions } from '../../../types/types'; + +export function eksFormatter(_options: ClusterLinksFormatterOptions): URL { + throw new Error('EKS formatter is not yet implemented'); +} diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts new file mode 100644 index 0000000000..56e8b510d3 --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts @@ -0,0 +1,33 @@ +/* + * 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 { gkeFormatter } from './gke'; + +describe('clusterLinks - aks formatter', () => { + it('should return an url on the workloads when there is a namespace only', () => { + expect(() => + gkeFormatter({ + dashboardUrl: new URL('https://k8s.foo.com'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }), + ).toThrowError('GKE formatter is not yet implemented'); + }); +}); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts new file mode 100644 index 0000000000..858b872389 --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts @@ -0,0 +1,20 @@ +/* + * 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 { ClusterLinksFormatterOptions } from '../../../types/types'; + +export function gkeFormatter(_options: ClusterLinksFormatterOptions): URL { + throw new Error('GKE formatter is not yet implemented'); +} diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/index.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/index.ts new file mode 100644 index 0000000000..f0530f1f7b --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/index.ts @@ -0,0 +1,26 @@ +/* + * 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 { ClusterLinksFormatter } from '../../../types/types'; +import { standardFormatter } from './standard'; +import { rancherFormatter } from './rancher'; +import { openshiftFormatter } from './openshift'; + +export const clusterLinksFormatters: Record = { + standard: standardFormatter, + rancher: rancherFormatter, + openshift: openshiftFormatter, +}; +export const defaultFormatterName = 'standard'; diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts new file mode 100644 index 0000000000..f9a70d8d9a --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts @@ -0,0 +1,33 @@ +/* + * 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 { openshiftFormatter } from './openshift'; + +describe('clusterLinks - aks formatter', () => { + it('should return an url on the workloads when there is a namespace only', () => { + expect(() => + openshiftFormatter({ + dashboardUrl: new URL('https://k8s.foo.com'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }), + ).toThrowError('OpenShift formatter is not yet implemented'); + }); +}); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.ts new file mode 100644 index 0000000000..acab9ec1cb --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.ts @@ -0,0 +1,22 @@ +/* + * 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 { ClusterLinksFormatterOptions } from '../../../types/types'; + +export function openshiftFormatter( + _options: ClusterLinksFormatterOptions, +): URL { + throw new Error('OpenShift formatter is not yet implemented'); +} diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.test.ts new file mode 100644 index 0000000000..61de1a0a4e --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.test.ts @@ -0,0 +1,33 @@ +/* + * 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 { rancherFormatter } from './rancher'; + +describe('clusterLinks - aks formatter', () => { + it('should return an url on the workloads when there is a namespace only', () => { + expect(() => + rancherFormatter({ + dashboardUrl: new URL('https://k8s.foo.com'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }), + ).toThrowError('Rancher formatter is not yet implemented'); + }); +}); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.ts new file mode 100644 index 0000000000..962f494408 --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.ts @@ -0,0 +1,20 @@ +/* + * 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 { ClusterLinksFormatterOptions } from '../../../types/types'; + +export function rancherFormatter(_options: ClusterLinksFormatterOptions): URL { + throw new Error('Rancher formatter is not yet implemented'); +} diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.test.ts new file mode 100644 index 0000000000..76bbf5643d --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.test.ts @@ -0,0 +1,115 @@ +/* + * 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 { standardFormatter } from './standard'; + +function formatUrl(url: URL) { + // Note that we can't rely on 'url.href' since it will put the search before the hash + // and this won't be properly recognized by SPAs such as Angular in the standard dashboard. + // Note also that pathname, hash and search will be properly url encoded. + return `${url.origin}${url.pathname}${url.hash}${url.search}`; +} + +describe('clusterLinks - standard formatter', () => { + it('should return an url on the workloads when there is a namespace only', () => { + const url = standardFormatter({ + dashboardUrl: new URL('https://k8s.foo.com'), + object: { + metadata: { + namespace: 'bar', + }, + }, + kind: 'foo', + }); + expect(formatUrl(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 = standardFormatter({ + dashboardUrl: new URL('https://k8s.foo.com'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'UnknownKind', + }); + expect(formatUrl(url)).toBe( + 'https://k8s.foo.com/#/workloads?namespace=bar', + ); + }); + it('should return an url on the deployment', () => { + const url = standardFormatter({ + dashboardUrl: new URL('https://k8s.foo.com/'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }); + expect(formatUrl(url)).toBe( + 'https://k8s.foo.com/#/deployment/bar/foobar?namespace=bar', + ); + }); + it('should return an url on the service', () => { + const url = standardFormatter({ + dashboardUrl: new URL('https://k8s.foo.com/'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Service', + }); + expect(formatUrl(url)).toBe( + 'https://k8s.foo.com/#/service/bar/foobar?namespace=bar', + ); + }); + it('should return an url on the ingress', () => { + const url = standardFormatter({ + dashboardUrl: new URL('https://k8s.foo.com/'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Ingress', + }); + expect(formatUrl(url)).toBe( + 'https://k8s.foo.com/#/ingress/bar/foobar?namespace=bar', + ); + }); + it('should return an url on the deployment for a hpa', () => { + const url = standardFormatter({ + dashboardUrl: new URL('https://k8s.foo.com/'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'HorizontalPodAutoscaler', + }); + expect(formatUrl(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/formatters/standard.ts similarity index 62% rename from plugins/kubernetes/src/utils/clusterLinks.ts rename to plugins/kubernetes/src/utils/clusterLinks/formatters/standard.ts index 53f771a1b6..06a8f9e0a8 100644 --- a/plugins/kubernetes/src/utils/clusterLinks.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { ClusterLinksFormatterOptions } from '../../../types/types'; const KindMappings: Record = { deployment: 'deployment', @@ -21,30 +22,18 @@ const KindMappings: Record = { horizontalpodautoscaler: 'deployment', }; -export function formatClusterLink(options: { - dashboardUrl?: string; - object: any; - kind: string; -}) { - if (!options.dashboardUrl) { - return undefined; - } - if (!options.object) { - return options.dashboardUrl; - } - const host = options.dashboardUrl.endsWith('/') - ? options.dashboardUrl - : `${options.dashboardUrl}/`; +export function standardFormatter(options: ClusterLinksFormatterOptions) { + const result = new URL(options.dashboardUrl.href); const name = options.object.metadata?.name; const namespace = options.object.metadata?.namespace; const validKind = KindMappings[options.kind.toLocaleLowerCase()]; - if (validKind && name && namespace) { - return `${host}#/${encodeURIComponent(validKind)}/${encodeURIComponent( - namespace, - )}/${encodeURIComponent(name)}?namespace=${encodeURIComponent(namespace)}`; - } if (namespace) { - return `${host}#/workloads?namespace=${encodeURIComponent(namespace)}`; + result.searchParams.set('namespace', namespace); } - return options.dashboardUrl; + if (validKind && name && namespace) { + result.hash = `/${validKind}/${namespace}/${name}`; + } else if (namespace) { + result.hash = '/workloads'; + } + return result; } diff --git a/plugins/kubernetes/src/utils/clusterLinks/index.ts b/plugins/kubernetes/src/utils/clusterLinks/index.ts new file mode 100644 index 0000000000..0a2a32cdbf --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { formatClusterLink } from './formatClusterLink'; +export { clusterLinksFormatters } from './formatters'; diff --git a/plugins/kubernetes/src/utils/index.ts b/plugins/kubernetes/src/utils/index.ts new file mode 100644 index 0000000000..d47afaf668 --- /dev/null +++ b/plugins/kubernetes/src/utils/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './clusterLinks'; From 06cc986113d2a57b9e90f6e601c5dbe9d72ab3f8 Mon Sep 17 00:00:00 2001 From: Morgan Martinet Date: Sun, 29 Aug 2021 19:32:22 -0400 Subject: [PATCH 07/45] fix documentation errors Signed-off-by: Morgan Martinet --- docs/features/kubernetes/configuration.md | 9 +++++---- plugins/kubernetes-backend/src/types/types.ts | 2 +- plugins/kubernetes-common/src/types.ts | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index 737a753635..4bbc28c178 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -115,13 +115,14 @@ Specifies the app that provides the Kubernetes dashboard. This will be used for formatting links to kubernetes objects inside the dashboard. -The supported dashboards are: standard, rancher, openshift, gke, aks, eks +The supported dashboards are: `standard`, `rancher`, `openshift`, `gke`, `aks`, +`eks` Note that it will default to the regular dashboard provided by the Kubernetes -project (standard), that can run in any kubernetes cluster. +project (`standard`), that can run in any Kubernetes cluster. -Note that you can add your own formatter by registering it to the formatters -dictionary, in the app project. +Note that you can add your own formatter by registering it to the +`clusterLinksFormatters` dictionary, in the app project. Example: diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index fe62d0fd78..3f04ea80d9 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -98,7 +98,7 @@ export interface ClusterDetails { * @remarks * The existing apps are: standard, rancher, openshift, gke, aks, eks * Note that it will default to the regular dashboard provided by the Kubernetes project (standard). - * Note that you can add your own formatter by registering it to the formatters dictionary. + * Note that you can add your own formatter by registering it to the clusterLinksFormatters dictionary. * @defaultValue standard * @see dashboardUrl * @example diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index 8b8aba9a93..6eda82f9b7 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -52,7 +52,7 @@ export interface ClusterAttributes { * @remarks * The supported dashboards are: standard, rancher, openshift, gke, aks, eks * Note that it will default to the regular dashboard provided by the Kubernetes project (standard). - * Note that you can add your own formatter by registering it to the formatters dictionary. + * Note that you can add your own formatter by registering it to the clusterLinksFormatters dictionary. * @defaultValue standard * @see dashboardUrl * @example From 991356f3afc7794a224e227e2235c1dfaa87ff27 Mon Sep 17 00:00:00 2001 From: Morgan Martinet Date: Sun, 29 Aug 2021 20:30:51 -0400 Subject: [PATCH 08/45] add missing link formatters to the registry Signed-off-by: Morgan Martinet --- .../src/utils/clusterLinks/formatters/aks.test.ts | 2 +- .../src/utils/clusterLinks/formatters/eks.test.ts | 2 +- .../src/utils/clusterLinks/formatters/gke.test.ts | 2 +- .../kubernetes/src/utils/clusterLinks/formatters/index.ts | 6 ++++++ .../src/utils/clusterLinks/formatters/openshift.test.ts | 2 +- .../src/utils/clusterLinks/formatters/rancher.test.ts | 2 +- 6 files changed, 11 insertions(+), 5 deletions(-) diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.test.ts index 381df0dc28..847cbf83ef 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.test.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.test.ts @@ -15,7 +15,7 @@ */ import { aksFormatter } from './aks'; -describe('clusterLinks - aks formatter', () => { +describe('clusterLinks - AKS formatter', () => { it('should return an url on the workloads when there is a namespace only', () => { expect(() => aksFormatter({ diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.test.ts index a1a5252bc4..808901e60e 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.test.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.test.ts @@ -15,7 +15,7 @@ */ import { eksFormatter } from './eks'; -describe('clusterLinks - aks formatter', () => { +describe('clusterLinks - EKS formatter', () => { it('should return an url on the workloads when there is a namespace only', () => { expect(() => eksFormatter({ diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts index 56e8b510d3..6a842e0c24 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts @@ -15,7 +15,7 @@ */ import { gkeFormatter } from './gke'; -describe('clusterLinks - aks formatter', () => { +describe('clusterLinks - GKE formatter', () => { it('should return an url on the workloads when there is a namespace only', () => { expect(() => gkeFormatter({ diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/index.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/index.ts index f0530f1f7b..a2dea97555 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/index.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/index.ts @@ -17,10 +17,16 @@ import { ClusterLinksFormatter } from '../../../types/types'; import { standardFormatter } from './standard'; import { rancherFormatter } from './rancher'; import { openshiftFormatter } from './openshift'; +import { aksFormatter } from './aks'; +import { eksFormatter } from './eks'; +import { gkeFormatter } from './gke'; export const clusterLinksFormatters: Record = { standard: standardFormatter, rancher: rancherFormatter, openshift: openshiftFormatter, + aks: aksFormatter, + eks: eksFormatter, + gke: gkeFormatter, }; export const defaultFormatterName = 'standard'; diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts index f9a70d8d9a..67ef5e6c51 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts @@ -15,7 +15,7 @@ */ import { openshiftFormatter } from './openshift'; -describe('clusterLinks - aks formatter', () => { +describe('clusterLinks - OpenShift formatter', () => { it('should return an url on the workloads when there is a namespace only', () => { expect(() => openshiftFormatter({ diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.test.ts index 61de1a0a4e..503470acd3 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.test.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.test.ts @@ -15,7 +15,7 @@ */ import { rancherFormatter } from './rancher'; -describe('clusterLinks - aks formatter', () => { +describe('clusterLinks - Rancher formatter', () => { it('should return an url on the workloads when there is a namespace only', () => { expect(() => rancherFormatter({ From a65206d5a4d90f3ca34385862261bd48d28e616e Mon Sep 17 00:00:00 2001 From: Morgan Martinet Date: Sun, 5 Sep 2021 20:02:07 -0400 Subject: [PATCH 09/45] minor changes after code review Signed-off-by: Morgan Martinet --- docs/features/kubernetes/configuration.md | 6 +-- plugins/kubernetes-backend/src/types/types.ts | 4 +- plugins/kubernetes-common/src/types.ts | 4 +- plugins/kubernetes/api-report.md | 10 ++-- .../KubernetesDrawer/KubernetesDrawer.tsx | 49 ++++++++++++++++++- .../utils/clusterLinks/formatClusterLink.ts | 6 ++- .../utils/clusterLinks/formatters/aks.test.ts | 2 +- .../src/utils/clusterLinks/formatters/aks.ts | 2 +- .../utils/clusterLinks/formatters/eks.test.ts | 2 +- .../src/utils/clusterLinks/formatters/eks.ts | 2 +- .../utils/clusterLinks/formatters/gke.test.ts | 2 +- .../src/utils/clusterLinks/formatters/gke.ts | 2 +- .../clusterLinks/formatters/openshift.test.ts | 4 +- .../clusterLinks/formatters/openshift.ts | 4 +- .../clusterLinks/formatters/rancher.test.ts | 4 +- .../utils/clusterLinks/formatters/rancher.ts | 4 +- .../utils/clusterLinks/formatters/standard.ts | 4 +- 17 files changed, 83 insertions(+), 28 deletions(-) diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index 4bbc28c178..f48faa202d 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -104,9 +104,9 @@ kubectl -n get secret $(kubectl -n get sa JSX.Element; +// Warning: (ae-forgotten-export) The symbol "FormatClusterLinkOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "formatClusterLink" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function formatClusterLink(options: { - dashboardUrl?: string; - dashboardApp?: string; - object: any; - kind: string; -}): string | undefined; +export function formatClusterLink( + options: FormatClusterLinkOptions, +): string | undefined; // Warning: (ae-forgotten-export) The symbol "KubernetesAuthProvidersApi" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "KubernetesAuthProviders" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx index 4a51655484..78f8aa3d5c 100644 --- a/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx +++ b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx @@ -36,9 +36,12 @@ import { CodeSnippet, StructuredMetadataTable, Link, + 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'; const useDrawerStyles = makeStyles((theme: Theme) => createStyles({ @@ -57,6 +60,10 @@ const useDrawerContentStyles = makeStyles((_: Theme) => flexDirection: 'row', justifyContent: 'space-between', }, + errorMessage: { + marginTop: '1em', + marginBottom: '1em', + }, options: { display: 'flex', flexDirection: 'row', @@ -80,6 +87,27 @@ const PodDrawerButton = withStyles({ }, })(Button); +type ErrorPanelProps = { + cluster: ClusterAttributes; + errorMessage?: string; + children?: React.ReactNode; +}; + +export const ErrorPanel = ({ cluster, errorMessage }: ErrorPanelProps) => ( + + {errorMessage && ( + Errors: {errorMessage} + )} + +); + interface KubernetesDrawerable { metadata?: V1ObjectMeta; } @@ -100,6 +128,20 @@ function replaceNullsWithUndefined(someObj: any) { 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 = ({ toggleDrawer, object, @@ -110,7 +152,7 @@ const KubernetesDrawerContent = ({ const classes = useDrawerContentStyles(); const cluster = useContext(ClusterContext); - const clusterLink = formatClusterLink({ + const { clusterLink, errorMessage } = tryFormatClusterLink({ dashboardUrl: cluster.dashboardUrl, dashboardApp: cluster.dashboardApp, object, @@ -146,6 +188,11 @@ const KubernetesDrawerContent = ({
+ {errorMessage && ( +
+ +
+ )}
{clusterLink && ( diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts b/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts index f04ec1daea..83e970248b 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts @@ -16,12 +16,14 @@ import { defaultFormatterName, clusterLinksFormatters } from './formatters'; -export function formatClusterLink(options: { +export type FormatClusterLinkOptions = { dashboardUrl?: string; dashboardApp?: string; object: any; kind: string; -}) { +}; + +export function formatClusterLink(options: FormatClusterLinkOptions) { if (!options.dashboardUrl) { return undefined; } diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.test.ts index 847cbf83ef..320c06e54c 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.test.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.test.ts @@ -28,6 +28,6 @@ describe('clusterLinks - AKS formatter', () => { }, kind: 'Deployment', }), - ).toThrowError('AKS formatter is not yet implemented'); + ).toThrowError('AKS formatter is not yet implemented. Please, contribute!'); }); }); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.ts index ef221b604d..d6f39ab72c 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.ts @@ -16,5 +16,5 @@ import { ClusterLinksFormatterOptions } from '../../../types/types'; export function aksFormatter(_options: ClusterLinksFormatterOptions): URL { - throw new Error('AKS formatter is not yet implemented'); + throw new Error('AKS formatter is not yet implemented. Please, contribute!'); } diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.test.ts index 808901e60e..5998bfde5e 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.test.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.test.ts @@ -28,6 +28,6 @@ describe('clusterLinks - EKS formatter', () => { }, kind: 'Deployment', }), - ).toThrowError('EKS formatter is not yet implemented'); + ).toThrowError('EKS formatter is not yet implemented. Please, contribute!'); }); }); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.ts index a9fb2f265b..975c39797c 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.ts @@ -16,5 +16,5 @@ import { ClusterLinksFormatterOptions } from '../../../types/types'; export function eksFormatter(_options: ClusterLinksFormatterOptions): URL { - throw new Error('EKS formatter is not yet implemented'); + throw new Error('EKS formatter is not yet implemented. Please, contribute!'); } diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts index 6a842e0c24..e566404daf 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts @@ -28,6 +28,6 @@ describe('clusterLinks - GKE formatter', () => { }, kind: 'Deployment', }), - ).toThrowError('GKE formatter is not yet implemented'); + ).toThrowError('GKE formatter is not yet implemented. Please, contribute!'); }); }); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts index 858b872389..30e7fa3123 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts @@ -16,5 +16,5 @@ import { ClusterLinksFormatterOptions } from '../../../types/types'; export function gkeFormatter(_options: ClusterLinksFormatterOptions): URL { - throw new Error('GKE formatter is not yet implemented'); + throw new Error('GKE formatter is not yet implemented. Please, contribute!'); } diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts index 67ef5e6c51..df2529a8ac 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts @@ -28,6 +28,8 @@ describe('clusterLinks - OpenShift formatter', () => { }, kind: 'Deployment', }), - ).toThrowError('OpenShift formatter is not yet implemented'); + ).toThrowError( + 'OpenShift formatter is not yet implemented. Please, contribute!', + ); }); }); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.ts index acab9ec1cb..bacb747ebb 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.ts @@ -18,5 +18,7 @@ import { ClusterLinksFormatterOptions } from '../../../types/types'; export function openshiftFormatter( _options: ClusterLinksFormatterOptions, ): URL { - throw new Error('OpenShift formatter is not yet implemented'); + throw new Error( + 'OpenShift formatter is not yet implemented. Please, contribute!', + ); } diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.test.ts index 503470acd3..754647d5e9 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.test.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.test.ts @@ -28,6 +28,8 @@ describe('clusterLinks - Rancher formatter', () => { }, kind: 'Deployment', }), - ).toThrowError('Rancher formatter is not yet implemented'); + ).toThrowError( + 'Rancher formatter is not yet implemented. Please, contribute!', + ); }); }); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.ts index 962f494408..491ca032a9 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.ts @@ -16,5 +16,7 @@ import { ClusterLinksFormatterOptions } from '../../../types/types'; export function rancherFormatter(_options: ClusterLinksFormatterOptions): URL { - throw new Error('Rancher formatter is not yet implemented'); + throw new Error( + 'Rancher formatter is not yet implemented. Please, contribute!', + ); } diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.ts index 06a8f9e0a8..fb26cf220d 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.ts @@ -15,7 +15,7 @@ */ import { ClusterLinksFormatterOptions } from '../../../types/types'; -const KindMappings: Record = { +const kindMappings: Record = { deployment: 'deployment', ingress: 'ingress', service: 'service', @@ -26,7 +26,7 @@ export function standardFormatter(options: ClusterLinksFormatterOptions) { const result = new URL(options.dashboardUrl.href); const name = options.object.metadata?.name; const namespace = options.object.metadata?.namespace; - const validKind = KindMappings[options.kind.toLocaleLowerCase()]; + const validKind = kindMappings[options.kind.toLocaleLowerCase('en-US')]; if (namespace) { result.searchParams.set('namespace', namespace); } From 8fcdfaf1afcbbd4e1872073ae2cc03201706d2bc Mon Sep 17 00:00:00 2001 From: Morgan Martinet Date: Sun, 5 Sep 2021 21:05:56 -0400 Subject: [PATCH 10/45] fix appearance of the dashboard link button Signed-off-by: Morgan Martinet --- .../src/components/KubernetesDrawer/KubernetesDrawer.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx index 78f8aa3d5c..b4ea31dc8d 100644 --- a/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx +++ b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx @@ -28,6 +28,7 @@ import { 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 jsYaml from 'js-yaml'; @@ -35,7 +36,6 @@ import { Button as BackstageButton, CodeSnippet, StructuredMetadataTable, - Link, WarningPanel, } from '@backstage/core-components'; import { ClusterContext } from '../../hooks'; @@ -197,13 +197,13 @@ const KubernetesDrawerContent = ({
{clusterLink && ( } > - Open Kubernetes Dashboard... + Open Kubernetes Dashboard )}
From d646407c7686964fc3617b6ea6a9fefffacb6935 Mon Sep 17 00:00:00 2001 From: Michael Stergianis Date: Thu, 16 Sep 2021 14:49:28 -0400 Subject: [PATCH 11/45] Update plugin software catalog integration docs Updates ./docs/plugins/integrating-plugin-into-software-catalog.md as the docs had fallen out of date. Prefers educating plugin authors about useEntity as that is the defacto method for reading entities. Takes the author through an example of adding their plugin content to an existing entity element. Signed-off-by: Michael Stergianis Signed-off-by: Daniel Bravo --- ...ntegrating-plugin-into-software-catalog.md | 172 +++++++++--------- 1 file changed, 83 insertions(+), 89 deletions(-) diff --git a/docs/plugins/integrating-plugin-into-software-catalog.md b/docs/plugins/integrating-plugin-into-software-catalog.md index ae7c3189da..2f8de73a53 100644 --- a/docs/plugins/integrating-plugin-into-software-catalog.md +++ b/docs/plugins/integrating-plugin-into-software-catalog.md @@ -10,8 +10,8 @@ description: How to integrate a plugin into software catalog ## Steps 1. [Create a plugin](#create-a-plugin) -1. [Export a router with relative routes](#export-a-router) -1. [Import and use router in the APP](#import-and-use-router-in-the-app) +1. [Reading entities from within your plugin](#reading-entities-from-within-your-plugin) +1. [Import your plugin and embed in the entities page](#import-your-plugin-and-embed-in-the-entities-page) ### Create a plugin @@ -28,98 +28,92 @@ $ yarn create-plugin Creating the plugin... ``` -### Export a router +### Reading entities from within your plugin -Now in the plugin you have a `Router.tsx` file in the `src` folder. By default -it contains only one example route. Create a routing structure needed for your -plugin, keeping in mind that the whole set of routes defined here are going to -be mounted under some different route in the App. - -Example: - -`my-plugin` consists of 2 different views - `/me` and `/about`. I envision -people integrating it into plugin catalog as a tab named "MyPlugin". Then, my -`Routes.tsx` for the plugin is going to look like: +You can access the currently selected entity using the backstage api +`useEntity`. For example, ```tsx - - } /> - } /> - -``` +import { useEntity } from '@backstage/plugin-catalog-react'; -(where MePage and AboutPage are 2 components defined in your plugin and imported -accordingly inside `Router.tsx`) +export const MyPluginEntityContent = () => { + const { entity, loading, error, refresh } = useEntity(); -> Pay attention, if your `MePage` references the `AboutPage` it needs to do it -> through link to `about`, not `/about`. This allows react-router v6 to enable -> its relative routing mechanism. Read more - -> https://reacttraining.com/blog/react-router-v6-pre/#relative-route-path-and-link-to - -### Import and use router in the APP - -In the `app/src/components/catalog/EntityPage.tsx` (app === your folder, -containing Backstage app) import your created Router: - -```tsx -import { Router as MyPluginRouter } from '@backstage/plugin-my-plugin; -``` - -Now, you need to mount `MyPluginRouter` onto some route, for example if you had: - -```tsx -const DefaultEntityPage = ({ entity }: { entity: Entity }) => ( - - } - /> - -); -``` - -after you add your code it becomes: - -```tsx -const DefaultEntityPage = ({ entity }: { entity: Entity }) => ( - - } - /> - } - /> - -); -``` - -All of magic happens thanks to the `EntityPageLayout` component, which comes as -an export from `@backstage/plugin-catalog` package. - -```tsx -type EntityPageLayoutContentProps = { - /** - * Going to be transformed into react-router v6 - * path under the hood. Read more at https://reacttraining.com/blog/react-router-v6-pre - */ - path: string; - /** - * Gets transformed into the title for the tab - */ - title: string; - /** - * Element that is rendered when the location - * matches the path provided - */ - element: JSX.Element; + // Do something with the entity data... }; ``` -> You can either pass the entity from App to the plugin's router as a prop or -> use `useEntity` hook from `@backstage/plugin-catalog` directly inside your -> plugin. +Internally `useEntity` makes use of +[react `Context`s](https://reactjs.org/docs/context.html). The entity context is +provided by the entity page into which your plugin will be embedded. + +### Import your plugin and embed in the entities page + +To begin, you will need to import your plugin in the entities page. Located at +`packages/app/src/components/Catalog/EntityPage.tsx` from the root package of +your backstage app. + +```tsx +import { MyPluginEntityContent } from '@backstage/plugin-my-plugin; +``` + +To add your component to the Entity view, you will need to modify the +`packages/app/src/components/Catalog/EntityPage.tsx`. Depending on the needs of +your plugin, you may only care about certain kinds of +[entities](https://backstage.io/docs/features/software-catalog/descriptor-format), +each of which has its own +[element](https://reactjs.org/docs/rendering-elements.html) for rendering. This +functionality is handled by the `EntitySwitch` component: + +```tsx +export const entityPage = ( + + + + + + + + + {defaultEntityPage} + +); +``` + +At this point, you will need to modify the specific page where you want your +component to appear. If you are extending the Software Catalog model you will +need to add a new case to the `EntitySwitch`. For adding a plugin to an existing +component type, you modify the existing page. For example, if you want to add +your plugin to the `systemPage`, you can add a new tab by adding an +`EntityLayout.Route` such as below: + +```tsx +const systemPage = ( + + + + + + + + + + + + + + + + + + + + + + {/* Adding a new tab to the system view */} + + + + +); +``` From c850933d83e62f9f0c5bc927cbcbea8dfa224351 Mon Sep 17 00:00:00 2001 From: Anis Jonischkeit Date: Fri, 17 Sep 2021 11:53:10 +1000 Subject: [PATCH 12/45] DOCS: mark username optional as it's not needed when using a token --- docs/integrations/bitbucket/locations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/integrations/bitbucket/locations.md b/docs/integrations/bitbucket/locations.md index d318eef874..2c53db70d4 100644 --- a/docs/integrations/bitbucket/locations.md +++ b/docs/integrations/bitbucket/locations.md @@ -31,7 +31,7 @@ a structure with up to four elements: - `host`: The host of the Bitbucket instance, e.g. `bitbucket.company.com`. - `token` (optional): An personal access token as expected by Bitbucket. Either an access token **or** a username + appPassword may be supplied. -- `username`: The Bitbucket username to use in API requests. If neither a +- `username` (optional): The Bitbucket username to use in API requests. If neither a username nor token are supplied, anonymous access will be used. - `appPassword` (optional): The password for the Bitbucket user. Only needed when using `username` instead of `token`. From dbcaa6387a3779197778c38d7ed29f68e51274c5 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 14 Sep 2021 13:18:45 +0200 Subject: [PATCH 13/45] Catalog: Add refresh button to AboutCard Co-authored-by: Patrik Oldsberg Signed-off-by: Johan Haals --- .changeset/nice-lions-hug.md | 5 ++ .changeset/slimy-impalas-admire.md | 6 ++ packages/catalog-client/src/CatalogClient.ts | 18 +++++ packages/catalog-client/src/types/api.ts | 4 ++ .../lib/catalog/CatalogIdentityClient.test.ts | 1 + .../src/providers/aws-alb/provider.test.ts | 2 +- .../badges-backend/src/service/router.test.ts | 1 + .../src/api/CatalogImportClient.test.ts | 1 + .../StepPrepareCreatePullRequest.test.tsx | 1 + plugins/catalog/src/CatalogClientWrapper.ts | 9 +++ .../components/AboutCard/AboutCard.test.tsx | 68 ++++++++++++++++--- .../src/components/AboutCard/AboutCard.tsx | 41 ++++++++--- .../DefaultExplorePage.test.tsx | 1 + .../DomainExplorerContent.test.tsx | 1 + .../GroupsExplorerContent.test.tsx | 1 + .../components/FossaPage/FossaPage.test.tsx | 1 + .../src/service/TodoReaderService.test.ts | 1 + 17 files changed, 142 insertions(+), 20 deletions(-) create mode 100644 .changeset/nice-lions-hug.md create mode 100644 .changeset/slimy-impalas-admire.md diff --git a/.changeset/nice-lions-hug.md b/.changeset/nice-lions-hug.md new file mode 100644 index 0000000000..69dd33fceb --- /dev/null +++ b/.changeset/nice-lions-hug.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Updates the `AboutCard` with a refresh button that allows the entity to be scheduled for refresh. diff --git a/.changeset/slimy-impalas-admire.md b/.changeset/slimy-impalas-admire.md new file mode 100644 index 0000000000..853d3eee65 --- /dev/null +++ b/.changeset/slimy-impalas-admire.md @@ -0,0 +1,6 @@ +--- +'@backstage/catalog-client': minor +'@backstage/plugin-catalog-react': minor +--- + +Extends the `CatalogClient` interface with a `refreshEntity` method. diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 410d921d3b..3b697fc597 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -136,6 +136,24 @@ export class CatalogClient implements CatalogApi { ); } + async refreshEntity(entityRef: string, options?: CatalogRequestOptions) { + const response = await fetch( + `${await this.discoveryApi.getBaseUrl('catalog')}/refresh`, + { + headers: { + 'Content-Type': 'application/json', + ...(options?.token && { Authorization: `Bearer ${options?.token}` }), + }, + method: 'POST', + body: JSON.stringify({ entityRef }), + }, + ); + + if (response.status !== 200) { + throw new Error(await response.text()); + } + } + async addLocation( { type = 'url', target, dryRun, presence }: AddLocationRequest, options?: CatalogRequestOptions, diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index e78bd9d713..b95ea335c2 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -53,6 +53,10 @@ export interface CatalogApi { uid: string, options?: CatalogRequestOptions, ): Promise; + refreshEntity( + entityRef: string, + options?: CatalogRequestOptions, + ): Promise; // Locations getLocationById( diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts index 6ffcfd6f46..5dd3c768bb 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts @@ -33,6 +33,7 @@ describe('CatalogIdentityClient', () => { getOriginLocationByEntity: jest.fn(), getLocationByEntity: jest.fn(), removeEntityByUid: jest.fn(), + refreshEntity: jest.fn(), }; const tokenIssuer: jest.Mocked = { issueToken: jest.fn(), diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts index a1606e18f2..c9647176cd 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts @@ -68,7 +68,6 @@ beforeEach(() => { describe('AwsALBAuthProvider', () => { const catalogApi = { - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ addLocation: jest.fn(), removeLocationById: jest.fn(), getEntities: jest.fn(), @@ -77,6 +76,7 @@ describe('AwsALBAuthProvider', () => { getLocationById: jest.fn(), removeEntityByUid: jest.fn(), getEntityByName: jest.fn(), + refreshEntity: jest.fn(), }; const mockRequest = { diff --git a/plugins/badges-backend/src/service/router.test.ts b/plugins/badges-backend/src/service/router.test.ts index 5d32e64f19..5dc877ec32 100644 --- a/plugins/badges-backend/src/service/router.test.ts +++ b/plugins/badges-backend/src/service/router.test.ts @@ -66,6 +66,7 @@ describe('createRouter', () => { getLocationById: jest.fn(), removeLocationById: jest.fn(), removeEntityByUid: jest.fn(), + refreshEntity: jest.fn(), }; config = new ConfigReader({ diff --git a/plugins/catalog-import/src/api/CatalogImportClient.test.ts b/plugins/catalog-import/src/api/CatalogImportClient.test.ts index 2936f956e4..6a2881e760 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.test.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.test.ts @@ -104,6 +104,7 @@ describe('CatalogImportClient', () => { getLocationByEntity: jest.fn(), getLocationById: jest.fn(), removeEntityByUid: jest.fn(), + refreshEntity: jest.fn(), }; let catalogImportClient: CatalogImportClient; diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx index 08b8177291..5a4ccef9a2 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx @@ -42,6 +42,7 @@ describe('', () => { getLocationById: jest.fn(), removeLocationById: jest.fn(), removeEntityByUid: jest.fn(), + refreshEntity: jest.fn(), }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( diff --git a/plugins/catalog/src/CatalogClientWrapper.ts b/plugins/catalog/src/CatalogClientWrapper.ts index a46edb56b7..46ca8ec0f9 100644 --- a/plugins/catalog/src/CatalogClientWrapper.ts +++ b/plugins/catalog/src/CatalogClientWrapper.ts @@ -109,4 +109,13 @@ export class CatalogClientWrapper implements CatalogApi { token: options?.token ?? (await this.identityApi.getIdToken()), }); } + + async refreshEntity( + entityRef: string, + options?: CatalogRequestOptions, + ): Promise { + return await this.client.refreshEntity(entityRef, { + token: options?.token ?? (await this.identityApi.getIdToken()), + }); + } } diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx index 6c85ebcab7..1dbf849a2d 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx @@ -24,13 +24,28 @@ import { ScmIntegrationsApi, scmIntegrationsApiRef, } from '@backstage/integration-react'; -import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { + catalogApiRef, + EntityProvider, + CatalogApi, +} from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { viewTechDocRouteRef } from '../../routes'; import { AboutCard } from './AboutCard'; describe('', () => { + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + refreshEntity: jest.fn(), + } as any; + it('renders info', async () => { const entity = { apiVersion: 'v1', @@ -62,7 +77,7 @@ describe('', () => { integrations: {}, }), ), - ); + ).with(catalogApiRef, catalogApi); const { getByText } = await renderInTestApp( @@ -109,7 +124,7 @@ describe('', () => { }, }), ), - ); + ).with(catalogApiRef, catalogApi); const { getByText } = await renderInTestApp( @@ -155,7 +170,7 @@ describe('', () => { }, }), ), - ); + ).with(catalogApiRef, catalogApi); const { getByTitle } = await renderInTestApp( @@ -188,7 +203,7 @@ describe('', () => { const apis = ApiRegistry.with( scmIntegrationsApiRef, ScmIntegrationsApi.fromConfig(new ConfigReader({})), - ); + ).with(catalogApiRef, catalogApi); const { getByText } = await renderInTestApp( @@ -200,6 +215,43 @@ describe('', () => { expect(getByText('View Source').closest('a')).not.toHaveAttribute('href'); }); + it('triggers a refresh', async () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const apis = ApiRegistry.with( + scmIntegrationsApiRef, + ScmIntegrationsApi.fromConfig(new ConfigReader({})), + ).with(catalogApiRef, catalogApi); + + const { getByTitle } = await renderInTestApp( + + + + + , + ); + + expect(catalogApi.refreshEntity).not.toHaveBeenCalledWith( + 'component:default/software', + ); + + userEvent.click(getByTitle('Schedule entity refresh')); + + expect(catalogApi.refreshEntity).toHaveBeenCalledWith( + 'component:default/software', + ); + }); + it('renders techdocs link', async () => { const entity = { apiVersion: 'v1', @@ -230,7 +282,7 @@ describe('', () => { }, }), ), - ); + ).with(catalogApiRef, catalogApi); const { getByText } = await renderInTestApp( @@ -278,7 +330,7 @@ describe('', () => { }, }), ), - ); + ).with(catalogApiRef, catalogApi); const { getByText } = await renderInTestApp( @@ -321,7 +373,7 @@ describe('', () => { }, }), ), - ); + ).with(catalogApiRef, catalogApi); const { getByText } = await renderInTestApp( diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index 582b464afa..ec3d47a204 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -19,6 +19,7 @@ import { ENTITY_DEFAULT_NAMESPACE, RELATION_CONSUMES_API, RELATION_PROVIDES_API, + stringifyEntityRef, } from '@backstage/catalog-model'; import { HeaderIconLinkRow, @@ -26,12 +27,13 @@ import { InfoCardVariants, Link, } from '@backstage/core-components'; -import { useApi, useRouteRef } from '@backstage/core-plugin-api'; +import { alertApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; import { ScmIntegrationIcon, scmIntegrationsApiRef, } from '@backstage/integration-react'; import { + catalogApiRef, getEntityMetadataEditUrl, getEntityRelations, getEntitySourceLocation, @@ -45,10 +47,11 @@ import { IconButton, makeStyles, } from '@material-ui/core'; +import CachedIcon from '@material-ui/icons/Cached'; import DocsIcon from '@material-ui/icons/Description'; import EditIcon from '@material-ui/icons/Edit'; import ExtensionIcon from '@material-ui/icons/Extension'; -import React from 'react'; +import React, { useCallback } from 'react'; import { viewTechDocRouteRef } from '../../routes'; import { AboutContent } from './AboutContent'; @@ -82,6 +85,8 @@ export function AboutCard({ variant }: AboutCardProps) { const classes = useStyles(); const { entity } = useEntity(); const scmIntegrationsApi = useApi(scmIntegrationsApiRef); + const catalogApi = useApi(catalogApiRef); + const alertApi = useApi(alertApiRef); const viewTechdocLink = useRouteRef(viewTechDocRouteRef); const entitySourceLocation = getEntitySourceLocation( @@ -142,20 +147,34 @@ export function AboutCard({ variant }: AboutCardProps) { cardContentClass = classes.fullHeightCardContent; } + const refreshEntity = useCallback(async () => { + await catalogApi.refreshEntity(stringifyEntityRef(entity)); + alertApi.post({ message: 'Refresh scheduled', severity: 'info' }); + }, [catalogApi, alertApi, entity]); + return ( - - + <> + + + + + + + } subheader={ diff --git a/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx index fc12a7f660..84b922bef5 100644 --- a/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx +++ b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx @@ -31,6 +31,7 @@ describe('', () => { removeLocationById: jest.fn(), removeEntityByUid: jest.fn(), getEntityByName: jest.fn(), + refreshEntity: jest.fn(), }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx index db44631c4c..5fd2b02ee9 100644 --- a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx @@ -33,6 +33,7 @@ describe('', () => { removeLocationById: jest.fn(), removeEntityByUid: jest.fn(), getEntityByName: jest.fn(), + refreshEntity: jest.fn(), }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( diff --git a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx index 52def47f7c..56dfd59aa8 100644 --- a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx +++ b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx @@ -32,6 +32,7 @@ describe('', () => { removeLocationById: jest.fn(), removeEntityByUid: jest.fn(), getEntityByName: jest.fn(), + refreshEntity: jest.fn(), }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( diff --git a/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx b/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx index 1baeb36465..b9edb0746a 100644 --- a/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx +++ b/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx @@ -32,6 +32,7 @@ describe('', () => { getOriginLocationByEntity: jest.fn(), removeEntityByUid: jest.fn(), removeLocationById: jest.fn(), + refreshEntity: jest.fn(), }; const fossaApi: jest.Mocked = { getFindingSummary: jest.fn(), diff --git a/plugins/todo-backend/src/service/TodoReaderService.test.ts b/plugins/todo-backend/src/service/TodoReaderService.test.ts index a95387817a..777fb0acf3 100644 --- a/plugins/todo-backend/src/service/TodoReaderService.test.ts +++ b/plugins/todo-backend/src/service/TodoReaderService.test.ts @@ -50,6 +50,7 @@ function mockCatalogClient(entity?: Entity): jest.Mocked { getLocationById: jest.fn(), removeLocationById: jest.fn(), removeEntityByUid: jest.fn(), + refreshEntity: jest.fn(), }; if (entity) { mock.getEntityByName.mockReturnValue(entity); From 7e470a725f79a763a8e5c97ae22d159ce6992943 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 17 Sep 2021 09:23:47 +0200 Subject: [PATCH 14/45] Update api report Signed-off-by: Johan Haals --- packages/catalog-client/api-report.md | 10 ++++++++++ plugins/catalog/api-report.md | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md index a645f37ab8..c2b78aaa90 100644 --- a/packages/catalog-client/api-report.md +++ b/packages/catalog-client/api-report.md @@ -57,6 +57,11 @@ export interface CatalogApi { options?: CatalogRequestOptions, ): Promise; // (undocumented) + refreshEntity( + entityRef: string, + options?: CatalogRequestOptions, + ): Promise; + // (undocumented) removeEntityByUid( uid: string, options?: CatalogRequestOptions, @@ -102,6 +107,11 @@ export class CatalogClient implements CatalogApi { options?: CatalogRequestOptions, ): Promise; // (undocumented) + refreshEntity( + entityRef: string, + options?: CatalogRequestOptions, + ): Promise; + // (undocumented) removeEntityByUid( uid: string, options?: CatalogRequestOptions, diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md index 200a74277f..1ee0d841c9 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -88,6 +88,11 @@ export class CatalogClientWrapper implements CatalogApi { options?: CatalogRequestOptions, ): Promise; // (undocumented) + refreshEntity( + entityRef: string, + options?: CatalogRequestOptions, + ): Promise; + // (undocumented) removeEntityByUid( uid: string, options?: CatalogRequestOptions, From 8c50da66854bb335eb95ce499464974b93d0d915 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Wed, 15 Sep 2021 10:45:30 +0200 Subject: [PATCH 15/45] Add catalog graph plugin Signed-off-by: Oliver Sand --- .changeset/wicked-pugs-speak.md | 8 + microsite/data/plugins/catalog-graph.yaml | 9 + microsite/static/img/catalog-graph.svg | 1 + packages/app/package.json | 1 + packages/app/src/App.tsx | 53 +- .../app/src/components/catalog/EntityPage.tsx | 72 +- plugins/catalog-graph/.eslintrc.js | 3 + plugins/catalog-graph/README.md | 83 ++ plugins/catalog-graph/dev/index.tsx | 167 ++++ plugins/catalog-graph/package.json | 54 ++ .../CatalogGraphCard.test.tsx | 118 +++ .../CatalogGraphCard/CatalogGraphCard.tsx | 126 +++ .../src/components/CatalogGraphCard/index.ts | 16 + .../CatalogGraphPage.test.tsx | 168 ++++ .../CatalogGraphPage/CatalogGraphPage.tsx | 241 ++++++ .../CatalogGraphPage/DirectionFilter.test.tsx | 47 ++ .../CatalogGraphPage/DirectionFilter.tsx | 49 ++ .../CatalogGraphPage/MaxDepthFilter.test.tsx | 75 ++ .../CatalogGraphPage/MaxDepthFilter.tsx | 83 ++ .../SelectedKindsFilter.test.tsx | 107 +++ .../CatalogGraphPage/SelectedKindsFilter.tsx | 113 +++ .../SelectedRelationsFilter.test.tsx | 110 +++ .../SelectedRelationsFilter.tsx | 96 +++ .../CatalogGraphPage/SwitchFilter.test.tsx | 44 ++ .../CatalogGraphPage/SwitchFilter.tsx | 58 ++ .../src/components/CatalogGraphPage/index.ts | 16 + .../useCatalogGraphPage.test.ts | 133 ++++ .../CatalogGraphPage/useCatalogGraphPage.ts | 258 ++++++ .../EntityRelationsGraph/CustomLabel.test.tsx | 71 ++ .../EntityRelationsGraph/CustomLabel.tsx | 46 ++ .../EntityRelationsGraph/CustomNode.test.tsx | 122 +++ .../EntityRelationsGraph/CustomNode.tsx | 145 ++++ .../EntityKindIcon.test.tsx | 36 + .../EntityRelationsGraph/EntityKindIcon.tsx | 35 + .../EntityRelationsGraph.test.tsx | 410 ++++++++++ .../EntityRelationsGraph.tsx | 132 ++++ .../components/EntityRelationsGraph/index.ts | 20 + .../EntityRelationsGraph/relations.ts | 48 ++ .../components/EntityRelationsGraph/types.ts | 44 ++ .../useEntityRelationGraph.test.ts | 365 +++++++++ .../useEntityRelationGraph.ts | 90 +++ .../useEntityRelationNodesAndEdges.test.ts | 735 ++++++++++++++++++ .../useEntityRelationNodesAndEdges.ts | 170 ++++ .../useEntityStore.test.ts | 234 ++++++ .../EntityRelationsGraph/useEntityStore.ts | 120 +++ plugins/catalog-graph/src/components/index.ts | 16 + plugins/catalog-graph/src/extensions.tsx | 38 + plugins/catalog-graph/src/index.ts | 19 + plugins/catalog-graph/src/plugin.test.ts | 22 + plugins/catalog-graph/src/plugin.ts | 27 + plugins/catalog-graph/src/routes.ts | 29 + plugins/catalog-graph/src/setupTests.ts | 16 + yarn.lock | 13 +- 53 files changed, 5291 insertions(+), 21 deletions(-) create mode 100644 .changeset/wicked-pugs-speak.md create mode 100644 microsite/data/plugins/catalog-graph.yaml create mode 100644 microsite/static/img/catalog-graph.svg create mode 100644 plugins/catalog-graph/.eslintrc.js create mode 100644 plugins/catalog-graph/README.md create mode 100644 plugins/catalog-graph/dev/index.tsx create mode 100644 plugins/catalog-graph/package.json create mode 100644 plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx create mode 100644 plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx create mode 100644 plugins/catalog-graph/src/components/CatalogGraphCard/index.ts create mode 100644 plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx create mode 100644 plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx create mode 100644 plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx create mode 100644 plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx create mode 100644 plugins/catalog-graph/src/components/CatalogGraphPage/MaxDepthFilter.test.tsx create mode 100644 plugins/catalog-graph/src/components/CatalogGraphPage/MaxDepthFilter.tsx create mode 100644 plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx create mode 100644 plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.tsx create mode 100644 plugins/catalog-graph/src/components/CatalogGraphPage/SelectedRelationsFilter.test.tsx create mode 100644 plugins/catalog-graph/src/components/CatalogGraphPage/SelectedRelationsFilter.tsx create mode 100644 plugins/catalog-graph/src/components/CatalogGraphPage/SwitchFilter.test.tsx create mode 100644 plugins/catalog-graph/src/components/CatalogGraphPage/SwitchFilter.tsx create mode 100644 plugins/catalog-graph/src/components/CatalogGraphPage/index.ts create mode 100644 plugins/catalog-graph/src/components/CatalogGraphPage/useCatalogGraphPage.test.ts create mode 100644 plugins/catalog-graph/src/components/CatalogGraphPage/useCatalogGraphPage.ts create mode 100644 plugins/catalog-graph/src/components/EntityRelationsGraph/CustomLabel.test.tsx create mode 100644 plugins/catalog-graph/src/components/EntityRelationsGraph/CustomLabel.tsx create mode 100644 plugins/catalog-graph/src/components/EntityRelationsGraph/CustomNode.test.tsx create mode 100644 plugins/catalog-graph/src/components/EntityRelationsGraph/CustomNode.tsx create mode 100644 plugins/catalog-graph/src/components/EntityRelationsGraph/EntityKindIcon.test.tsx create mode 100644 plugins/catalog-graph/src/components/EntityRelationsGraph/EntityKindIcon.tsx create mode 100644 plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx create mode 100644 plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx create mode 100644 plugins/catalog-graph/src/components/EntityRelationsGraph/index.ts create mode 100644 plugins/catalog-graph/src/components/EntityRelationsGraph/relations.ts create mode 100644 plugins/catalog-graph/src/components/EntityRelationsGraph/types.ts create mode 100644 plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.test.ts create mode 100644 plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.ts create mode 100644 plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.ts create mode 100644 plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts create mode 100644 plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts create mode 100644 plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.ts create mode 100644 plugins/catalog-graph/src/components/index.ts create mode 100644 plugins/catalog-graph/src/extensions.tsx create mode 100644 plugins/catalog-graph/src/index.ts create mode 100644 plugins/catalog-graph/src/plugin.test.ts create mode 100644 plugins/catalog-graph/src/plugin.ts create mode 100644 plugins/catalog-graph/src/routes.ts create mode 100644 plugins/catalog-graph/src/setupTests.ts diff --git a/.changeset/wicked-pugs-speak.md b/.changeset/wicked-pugs-speak.md new file mode 100644 index 0000000000..620be91773 --- /dev/null +++ b/.changeset/wicked-pugs-speak.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-catalog-graph': patch +--- + +Add new plugin `@backstage/plugin-catalog-graph`. The catalog graph visualizes +the relations between entities, like ownership, grouping or API relationships. + +For more details on adding the plugin to your Backstage instance, [see the README](https://github.com/backstage/backstage/blob/master/plugins/catalog-graph/README.md). diff --git a/microsite/data/plugins/catalog-graph.yaml b/microsite/data/plugins/catalog-graph.yaml new file mode 100644 index 0000000000..336954b5dc --- /dev/null +++ b/microsite/data/plugins/catalog-graph.yaml @@ -0,0 +1,9 @@ +--- +title: Catalog Graph +author: SDA SE +authorUrl: https://sda.se/ +category: Discovery +description: Extend the Backstage Software Catalog with a graph that shows all entities and their relationships providing an easier way to discover the ecosystem. +documentation: https://github.com/backstage/backstage/blob/master/plugins/catalog-graph/README.md +iconUrl: img/catalog-graph.svg +npmPackageName: '@backstage/plugin-catalog-graph' diff --git a/microsite/static/img/catalog-graph.svg b/microsite/static/img/catalog-graph.svg new file mode 100644 index 0000000000..6cd13b0e66 --- /dev/null +++ b/microsite/static/img/catalog-graph.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/app/package.json b/packages/app/package.json index b5a5af180d..b997313485 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -13,6 +13,7 @@ "@backstage/plugin-api-docs": "^0.6.8", "@backstage/plugin-badges": "^0.2.9", "@backstage/plugin-catalog": "^0.6.15", + "@backstage/plugin-catalog-graph": "^0.1.0", "@backstage/plugin-catalog-import": "^0.5.21", "@backstage/plugin-catalog-react": "^0.4.6", "@backstage/plugin-circleci": "^0.2.23", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 81dbdf4cac..87984bece0 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -14,20 +14,34 @@ * limitations under the License. */ +import { + RELATION_API_CONSUMED_BY, + RELATION_API_PROVIDED_BY, + RELATION_CONSUMES_API, + RELATION_DEPENDENCY_OF, + RELATION_DEPENDS_ON, + RELATION_HAS_PART, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + RELATION_PART_OF, + RELATION_PROVIDES_API, +} from '@backstage/catalog-model'; import { createApp, FlatRoutes } from '@backstage/core-app-api'; import { AlertDisplay, OAuthRequestDialog, SignInPage, } from '@backstage/core-components'; -import { HomepageCompositionRoot } from '@backstage/plugin-home'; import { apiDocsPlugin, ApiExplorerPage } from '@backstage/plugin-api-docs'; import { CatalogEntityPage, CatalogIndexPage, catalogPlugin, } from '@backstage/plugin-catalog'; - +import { + CatalogGraphPage, + catalogGraphPlugin, +} from '@backstage/plugin-catalog-graph'; import { CatalogImportPage, catalogImportPlugin, @@ -40,12 +54,13 @@ import { import { ExplorePage, explorePlugin } from '@backstage/plugin-explore'; import { GcpProjectsPage } from '@backstage/plugin-gcp-projects'; import { GraphiQLPage } from '@backstage/plugin-graphiql'; +import { HomepageCompositionRoot } from '@backstage/plugin-home'; import { LighthousePage } from '@backstage/plugin-lighthouse'; import { NewRelicPage } from '@backstage/plugin-newrelic'; import { + ScaffolderFieldExtensions, ScaffolderPage, scaffolderPlugin, - ScaffolderFieldExtensions, } from '@backstage/plugin-scaffolder'; import { SearchPage } from '@backstage/plugin-search'; import { TechRadarPage } from '@backstage/plugin-tech-radar'; @@ -61,12 +76,11 @@ import React from 'react'; import { hot } from 'react-hot-loader/root'; import { Navigate, Route } from 'react-router'; import { apis } from './apis'; -import { Root } from './components/Root'; import { entityPage } from './components/catalog/EntityPage'; -import { searchPage } from './components/search/SearchPage'; -import { LowerCaseValuePickerFieldExtension } from './components/scaffolder/customScaffolderExtensions'; import { HomePage } from './components/home/HomePage'; - +import { Root } from './components/Root'; +import { LowerCaseValuePickerFieldExtension } from './components/scaffolder/customScaffolderExtensions'; +import { searchPage } from './components/search/SearchPage'; import { providers } from './identityProviders'; import * as plugins from './plugins'; @@ -95,6 +109,9 @@ const app = createApp({ createComponent: scaffolderPlugin.routes.root, viewTechDoc: techdocsPlugin.routes.docRoot, }); + bind(catalogGraphPlugin.externalRoutes, { + catalogEntity: catalogPlugin.routes.catalogEntity, + }); bind(apiDocsPlugin.externalRoutes, { createComponent: scaffolderPlugin.routes.root, }); @@ -125,6 +142,28 @@ const routes = ( {entityPage} } /> + + } + /> }> diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 4a392e0f05..7ccfb059ea 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -14,15 +14,24 @@ * limitations under the License. */ -import React, { ReactNode, useMemo, useState } from 'react'; -import BadgeIcon from '@material-ui/icons/CallToAction'; +import { + RELATION_API_CONSUMED_BY, + RELATION_API_PROVIDED_BY, + RELATION_CONSUMES_API, + RELATION_DEPENDENCY_OF, + RELATION_DEPENDS_ON, + RELATION_HAS_PART, + RELATION_PART_OF, + RELATION_PROVIDES_API, +} from '@backstage/catalog-model'; +import { EmptyState } from '@backstage/core-components'; import { EntityApiDefinitionCard, + EntityConsumedApisCard, EntityConsumingComponentsCard, EntityHasApisCard, - EntityProvidingComponentsCard, EntityProvidedApisCard, - EntityConsumedApisCard, + EntityProvidingComponentsCard, } from '@backstage/plugin-api-docs'; import { EntityBadgesDialog } from '@backstage/plugin-badges'; import { @@ -30,20 +39,24 @@ import { EntityDependsOnComponentsCard, EntityDependsOnResourcesCard, EntityHasComponentsCard, + EntityHasResourcesCard, EntityHasSubcomponentsCard, EntityHasSystemsCard, EntityLayout, EntityLinksCard, - EntitySystemDiagramCard, - EntitySwitch, - isComponentType, - isKind, - EntityHasResourcesCard, EntityOrphanWarning, EntityProcessingErrorsPanel, + EntitySwitch, + EntitySystemDiagramCard, hasCatalogProcessingErrors, + isComponentType, + isKind, isOrphan, } from '@backstage/plugin-catalog'; +import { + Direction, + EntityCatalogGraphCard, +} from '@backstage/plugin-catalog-graph'; import { EntityCircleCIContent, isCircleCIAvailable, @@ -52,6 +65,7 @@ import { EntityCloudbuildContent, isCloudbuildAvailable, } from '@backstage/plugin-cloudbuild'; +import { EntityCodeCoverageContent } from '@backstage/plugin-code-coverage'; import { EntityGithubActionsContent, EntityRecentGithubActionsRunsCard, @@ -87,6 +101,7 @@ import { EntitySentryContent } from '@backstage/plugin-sentry'; import { EntityTechdocsContent } from '@backstage/plugin-techdocs'; import { EntityTodoContent } from '@backstage/plugin-todo'; import { Button, Grid } from '@material-ui/core'; +import BadgeIcon from '@material-ui/icons/CallToAction'; import { EntityBuildkiteContent, isBuildkiteAvailable, @@ -108,8 +123,7 @@ import { EntityTravisCIOverviewCard, isTravisciAvailable, } from '@roadiehq/backstage-plugin-travis-ci'; -import { EntityCodeCoverageContent } from '@backstage/plugin-code-coverage'; -import { EmptyState } from '@backstage/core-components'; +import React, { ReactNode, useMemo, useState } from 'react'; const EntityLayoutWrapper = (props: { children?: ReactNode }) => { const [badgesDialogOpen, setBadgesDialogOpen] = useState(false); @@ -246,10 +260,14 @@ const errorsContent = ( const overviewContent = ( {entityWarningContent} - + + + + + @@ -454,9 +472,12 @@ const apiPage = ( {entityWarningContent} - + + + + @@ -523,6 +544,9 @@ const systemPage = ( + + + @@ -537,6 +561,25 @@ const systemPage = ( + + + ); @@ -548,6 +591,9 @@ const domainPage = ( + + + diff --git a/plugins/catalog-graph/.eslintrc.js b/plugins/catalog-graph/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/catalog-graph/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/catalog-graph/README.md b/plugins/catalog-graph/README.md new file mode 100644 index 0000000000..0e06e15f4c --- /dev/null +++ b/plugins/catalog-graph/README.md @@ -0,0 +1,83 @@ +# catalog-graph + +Welcome to the catalog graph plugin! The catalog graph visualizes the relations +between entities, like ownership, grouping or API relationships. + +The plugin comes with these features: + +- `EntityCatalogGraphCard`: + A card that displays the directly related entities to the current entity. + This card is for use on the entity page. + The card can be customized, for example filtering for specific relations. + +- `CatalogGraphPage`: + A standalone page that can be added to your application providing a viewer for your entities and their relations. + The viewer can be used to navigate through the entities and filter for specific relations. + You can access it from the `EntityCatalogGraphCard`. + +- `EntityRelationsGraph`: + A react component that can be used to build own customized entity relation graphs. + +## Usage + +To use the catalog graph plugin, you have to add some things to your Backstage app: + +1. Add a dependency to your `packages/app/package.json`, run: + ```sh + yarn add @backstage/plugin-catalog-graph + ``` +2. Add the `CatalogGraphPage` to your `packages/app/src/App.tsx`: + + ```typescript + + … + } />… + + ``` + + You can configure the page to open with some initial filters: + + ```typescript + + } + /> + ``` + +3. Bind the external routes of the `catalogGraphPlugin` in your `packages/app/src/App.tsx`: + + ```typescript + bindRoutes({ bind }) { + … + bind(catalogGraphPlugin.externalRoutes, { + catalogEntity: catalogPlugin.routes.catalogEntity, + }); + … + } + ``` + +4. Add `EntityCatalogGraphCard` to any entity page that you want in your `packages/app/src/components/catalog/EntityPage.tsx`: + + ```typescript + + + + ``` diff --git a/plugins/catalog-graph/dev/index.tsx b/plugins/catalog-graph/dev/index.tsx new file mode 100644 index 0000000000..ed14e5fce9 --- /dev/null +++ b/plugins/catalog-graph/dev/index.tsx @@ -0,0 +1,167 @@ +/* + * 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 { CatalogListResponse } from '@backstage/catalog-client'; +import { + Entity, + EntityName, + ENTITY_DEFAULT_NAMESPACE, + RELATION_API_CONSUMED_BY, + RELATION_API_PROVIDED_BY, + RELATION_CONSUMES_API, + RELATION_HAS_PART, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + RELATION_PART_OF, + RELATION_PROVIDES_API, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { Content, Header, Page } from '@backstage/core-components'; +import { createDevApp } from '@backstage/dev-utils'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { Grid } from '@material-ui/core'; +import React from 'react'; +import { + CatalogGraphPage, + catalogGraphPlugin, + EntityCatalogGraphCard, +} from '../src'; + +type DataRelation = [string, string, string]; +type DataEntity = [string, string, DataRelation[]]; + +const entities = ( + [ + [ + 'Domain', + 'wayback', + [ + [RELATION_OWNED_BY, 'Group', 'team-a'], + [RELATION_HAS_PART, 'System', 'wayback'], + ], + ], + [ + 'System', + 'wayback', + [ + [RELATION_OWNED_BY, 'Group', 'team-a'], + [RELATION_PART_OF, 'Domain', 'wayback'], + [RELATION_HAS_PART, 'Component', 'wayback-archive'], + [RELATION_HAS_PART, 'Component', 'wayback-search'], + [RELATION_HAS_PART, 'API', 'wayback-api'], + ], + ], + [ + 'Component', + 'wayback-archive', + [ + [RELATION_OWNED_BY, 'Group', 'team-a'], + [RELATION_PART_OF, 'System', 'wayback'], + [RELATION_PROVIDES_API, 'API', 'wayback-api'], + ], + ], + [ + 'Component', + 'wayback-search', + [ + [RELATION_OWNED_BY, 'Group', 'team-a'], + [RELATION_PART_OF, 'System', 'wayback'], + [RELATION_CONSUMES_API, 'API', 'wayback-api'], + ], + ], + [ + 'API', + 'wayback-api', + [ + [RELATION_OWNED_BY, 'Group', 'team-a'], + [RELATION_PART_OF, 'System', 'wayback'], + [RELATION_API_PROVIDED_BY, 'Component', 'wayback-archive'], + [RELATION_API_CONSUMED_BY, 'Component', 'wayback-search'], + ], + ], + [ + 'Group', + 'team-a', + [ + [RELATION_OWNER_OF, 'Component', 'wayback-archive'], + [RELATION_OWNER_OF, 'Component', 'wayback-search'], + [RELATION_OWNER_OF, 'API', 'wayback-api'], + [RELATION_OWNER_OF, 'Domain', 'wayback'], + [RELATION_OWNER_OF, 'System', 'wayback'], + ], + ], + ] as DataEntity[] +).reduce((o, d) => { + const [kind, name, relations] = d; + + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind, + metadata: { + name, + }, + relations: relations.map(([type, k, n]) => ({ + target: { kind: k, name: n, namespace: ENTITY_DEFAULT_NAMESPACE }, + type, + })), + }; + const entityRef = stringifyEntityRef(entity); + o[entityRef] = entity; + return o; +}, {} as { [entityRef: string]: Entity }); + +createDevApp() + .registerPlugin(catalogGraphPlugin) + .registerApi({ + api: catalogApiRef, + deps: {}, + factory() { + return { + async getEntityByName(name: EntityName): Promise { + return entities[stringifyEntityRef(name)]; + }, + async getEntities(): Promise> { + return { items: Object.values(entities) }; + }, + } as Partial as unknown as CatalogApi; + }, + }) + .addPage({ + title: 'Graph Card', + element: ( + +
+ + + + + + + + + + + ), + }) + .addPage({ + element: , + }) + .render(); diff --git a/plugins/catalog-graph/package.json b/plugins/catalog-graph/package.json new file mode 100644 index 0000000000..2a47979a06 --- /dev/null +++ b/plugins/catalog-graph/package.json @@ -0,0 +1,54 @@ +{ + "name": "@backstage/plugin-catalog-graph", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "private": true, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve --config ../../app-config.yaml", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/catalog-client": "^0.3.18", + "@backstage/catalog-model": "^0.9.2", + "@backstage/core-components": "^0.4.1", + "@backstage/core-plugin-api": "^0.1.7", + "@backstage/plugin-catalog-react": "^0.4.5", + "@backstage/theme": "^0.2.10", + "@material-ui/core": "^4.12.2", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.57", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-use": "^17.2.4", + "classnames": "^2.3.1", + "react-router": "6.0.0-beta.0", + "qs": "^6.9.4", + "lodash": "^4.17.15", + "p-limit": "^3.1.0" + }, + "devDependencies": { + "@backstage/cli": "^0.7.11", + "@backstage/dev-utils": "^0.2.9", + "@backstage/test-utils": "^0.1.17", + "@backstage/core-app-api": "^0.1.12", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^13.1.8", + "@testing-library/react-hooks": "^3.4.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx new file mode 100644 index 0000000000..514f8c2cf5 --- /dev/null +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx @@ -0,0 +1,118 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { catalogEntityRouteRef, catalogGraphRouteRef } from '../../routes'; +import { CatalogGraphCard } from './CatalogGraphCard'; + +describe('', () => { + let entity: Entity; + let wrapper: JSX.Element; + let catalog: jest.Mocked; + let apis: ApiRegistry; + + beforeAll(() => { + Object.defineProperty(window.SVGElement.prototype, 'getBBox', { + value: () => ({ width: 100, height: 100 }), + configurable: true, + }); + }); + + beforeEach(() => { + entity = { + apiVersion: 'a', + kind: 'b', + metadata: { + name: 'c', + namespace: 'd', + }, + }; + catalog = { + getEntities: jest.fn(), + getEntityByName: jest.fn(async _ => ({ ...entity, relations: [] })), + removeEntityByUid: jest.fn(), + getLocationById: jest.fn(), + getOriginLocationByEntity: jest.fn(), + getLocationByEntity: jest.fn(), + addLocation: jest.fn(), + removeLocationById: jest.fn(), + }; + apis = ApiRegistry.with(catalogApiRef, catalog); + + wrapper = ( + + + + + + ); + }); + + test('renders without exploding', async () => { + const { findByText, findAllByTestId } = await renderInTestApp(wrapper, { + mountedRoutes: { + '/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef, + '/catalog-graph': catalogGraphRouteRef, + }, + }); + + expect(await findByText('b:d/c')).toBeInTheDocument(); + expect(await findAllByTestId('node')).toHaveLength(1); + expect(catalog.getEntityByName).toBeCalledTimes(1); + }); + + test('renders with custom title', async () => { + const { findByText } = await renderInTestApp( + + + + + , + { + mountedRoutes: { + '/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef, + '/catalog-graph': catalogGraphRouteRef, + }, + }, + ); + + expect(await findByText('Custom Title')).toBeInTheDocument(); + }); + + test('renders link to standalone viewer', async () => { + const { findByText, getByText } = await renderInTestApp(wrapper, { + mountedRoutes: { + '/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef, + '/catalog-graph': catalogGraphRouteRef, + }, + }); + + expect(await findByText('b:d/c')).toBeInTheDocument(); + const button = getByText('View graph'); + expect(button).toBeInTheDocument(); + expect(button.closest('a')).toHaveAttribute( + 'href', + '/catalog-graph?rootEntityRefs%5B%5D=b%3Ad%2Fc', + ); + }); +}); diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx new file mode 100644 index 0000000000..78d02d6f3f --- /dev/null +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx @@ -0,0 +1,126 @@ +/* + * 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 { + getEntityName, + parseEntityRef, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { InfoCard, InfoCardVariants } from '@backstage/core-components'; +import { useRouteRef } from '@backstage/core-plugin-api'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { makeStyles, Theme } from '@material-ui/core'; +import qs from 'qs'; +import React, { MouseEvent, useCallback } from 'react'; +import { useNavigate } from 'react-router'; +import { catalogEntityRouteRef, catalogGraphRouteRef } from '../../routes'; +import { + Direction, + EntityNode, + EntityRelationsGraph, + RelationPairs, + RELATION_PAIRS, +} from '../EntityRelationsGraph'; + +const useStyles = makeStyles({ + card: ({ maxHeight }) => ({ + display: 'flex', + flexDirection: 'column', + maxHeight, + minHeight: 0, + }), + graph: { + flex: 1, + minHeight: 0, + }, +}); + +export type Props = { + variant?: InfoCardVariants; + relationPairs?: RelationPairs; + maxDepth?: number; + unidirectional?: boolean; + mergeRelations?: boolean; + kinds?: string[]; + relations?: string[]; + direction?: Direction; + maxHeight?: number; + title?: string; +}; + +export const CatalogGraphCard = ({ + variant = 'gridItem', + relationPairs = RELATION_PAIRS, + maxDepth = 1, + unidirectional = true, + mergeRelations = true, + kinds, + relations, + direction = Direction.LEFT_RIGHT, + maxHeight, + title = 'Relations', +}: Props) => { + const { entity } = useEntity(); + const entityName = getEntityName(entity); + const catalogEntityRoute = useRouteRef(catalogEntityRouteRef); + const catalogGraphRoute = useRouteRef(catalogGraphRouteRef); + const navigate = useNavigate(); + const classes = useStyles({ maxHeight }); + + const onNodeClick = useCallback( + (node: EntityNode, _: MouseEvent) => { + const nodeEntityName = parseEntityRef(node.id); + const path = catalogEntityRoute({ + kind: nodeEntityName.kind.toLowerCase(), + namespace: nodeEntityName.namespace.toLowerCase(), + name: nodeEntityName.name, + }); + navigate(path); + }, + [catalogEntityRoute, navigate], + ); + + const catalogGraphParams = qs.stringify( + { rootEntityRefs: [stringifyEntityRef(entity)] }, + { arrayFormat: 'brackets', addQueryPrefix: true }, + ); + const catalogGraphUrl = `${catalogGraphRoute()}${catalogGraphParams}`; + + return ( + + + + ); +}; diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/index.ts b/plugins/catalog-graph/src/components/CatalogGraphCard/index.ts new file mode 100644 index 0000000000..725f90331f --- /dev/null +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { CatalogGraphCard } from './CatalogGraphCard'; diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx new file mode 100644 index 0000000000..8902c49845 --- /dev/null +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx @@ -0,0 +1,168 @@ +/* + * 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 { RELATION_HAS_PART, RELATION_PART_OF } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { catalogEntityRouteRef } from '../../routes'; +import { CatalogGraphPage } from './CatalogGraphPage'; + +const navigate = jest.fn(); + +jest.mock('react-router', () => ({ + ...jest.requireActual('react-router'), + useNavigate: () => navigate, +})); + +describe('', () => { + let wrapper: JSX.Element; + let catalog: jest.Mocked; + + beforeAll(() => { + Object.defineProperty(window.SVGElement.prototype, 'getBBox', { + value: () => ({ width: 100, height: 100 }), + configurable: true, + }); + }); + + beforeEach(() => { + const entityC = { + apiVersion: 'a', + kind: 'b', + metadata: { + name: 'c', + namespace: 'd', + }, + relations: [ + { + type: RELATION_PART_OF, + target: { + kind: 'b', + namespace: 'd', + name: 'e', + }, + }, + ], + }; + const entityE = { + apiVersion: 'a', + kind: 'b', + metadata: { + name: 'e', + namespace: 'd', + }, + relations: [ + { + type: RELATION_HAS_PART, + target: { + kind: 'b', + namespace: 'd', + name: 'c', + }, + }, + ], + }; + catalog = { + getEntities: jest.fn(), + getEntityByName: jest.fn(async n => (n.name === 'e' ? entityE : entityC)), + removeEntityByUid: jest.fn(), + getLocationById: jest.fn(), + getOriginLocationByEntity: jest.fn(), + getLocationByEntity: jest.fn(), + addLocation: jest.fn(), + removeLocationById: jest.fn(), + }; + const apis = ApiRegistry.with(catalogApiRef, catalog); + + wrapper = ( + + + + ); + }); + + afterEach(() => jest.resetAllMocks()); + + test('should render without exploding', async () => { + const { getByText, findByText, findAllByTestId } = await renderInTestApp( + wrapper, + { + mountedRoutes: { + '/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef, + }, + }, + ); + + expect(getByText('Catalog Graph')).toBeInTheDocument(); + expect(await findByText('b:d/c')).toBeInTheDocument(); + expect(await findByText('b:d/e')).toBeInTheDocument(); + expect(await findAllByTestId('node')).toHaveLength(2); + expect(catalog.getEntityByName).toBeCalledTimes(2); + }); + + test('should toggle filters', async () => { + const { getByText, queryByText } = await renderInTestApp(wrapper, { + mountedRoutes: { + '/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef, + }, + }); + + expect(queryByText('Max Depth')).toBeNull(); + + userEvent.click(getByText('Filters')); + + expect(getByText('Max Depth')).toBeInTheDocument(); + }); + + test('should select other entity', async () => { + const { getByText, findByText, findAllByTestId } = await renderInTestApp( + wrapper, + { + mountedRoutes: { + '/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef, + }, + }, + ); + + expect(await findAllByTestId('node')).toHaveLength(2); + + userEvent.click(getByText('b:d/e')); + + expect(await findByText('hasPart')).toBeInTheDocument(); + }); + + test('should navigate to entity', async () => { + const { getByText, findAllByTestId } = await renderInTestApp(wrapper, { + mountedRoutes: { + '/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef, + }, + }); + + expect(await findAllByTestId('node')).toHaveLength(2); + + userEvent.click(getByText('b:d/e'), { shiftKey: true }); + + expect(navigate).toBeCalledWith('/entity/{kind}/{namespace}/{name}'); + }); +}); diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx new file mode 100644 index 0000000000..53b7a80414 --- /dev/null +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx @@ -0,0 +1,241 @@ +/* + * 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 { parseEntityRef } from '@backstage/catalog-model'; +import { + Content, + ContentHeader, + Header, + Page, + SupportButton, +} from '@backstage/core-components'; +import { useRouteRef } from '@backstage/core-plugin-api'; +import { formatEntityRefTitle } from '@backstage/plugin-catalog-react'; +import { Grid, makeStyles, Paper, Typography } from '@material-ui/core'; +import FilterListIcon from '@material-ui/icons/FilterList'; +import ZoomOutMap from '@material-ui/icons/ZoomOutMap'; +import { ToggleButton } from '@material-ui/lab'; +import React, { MouseEvent, useCallback } from 'react'; +import { useNavigate } from 'react-router'; +import { catalogEntityRouteRef } from '../../routes'; +import { + Direction, + EntityNode, + EntityRelationsGraph, + RelationPairs, + RELATION_PAIRS, +} from '../EntityRelationsGraph'; +import { DirectionFilter } from './DirectionFilter'; +import { MaxDepthFilter } from './MaxDepthFilter'; +import { SelectedKindsFilter } from './SelectedKindsFilter'; +import { SelectedRelationsFilter } from './SelectedRelationsFilter'; +import { SwitchFilter } from './SwitchFilter'; +import { useCatalogGraphPage } from './useCatalogGraphPage'; + +const useStyles = makeStyles(theme => ({ + content: { + minHeight: 0, + }, + container: { + height: '100%', + maxHeight: '100%', + minHeight: 0, + }, + fullHeight: { + maxHeight: '100%', + display: 'flex', + minHeight: 0, + }, + graphWrapper: { + position: 'relative', + flex: 1, + minHeight: 0, + display: 'flex', + }, + graph: { + flex: 1, + minHeight: 0, + }, + legend: { + position: 'absolute', + bottom: 0, + right: 0, + padding: theme.spacing(1), + '& .icon': { + verticalAlign: 'bottom', + }, + }, + filters: { + display: 'grid', + gridGap: theme.spacing(1), + gridAutoRows: 'auto', + [theme.breakpoints.up('lg')]: { + display: 'block', + }, + [theme.breakpoints.only('md')]: { + gridTemplateColumns: 'repeat(3, 1fr)', + }, + [theme.breakpoints.only('sm')]: { + gridTemplateColumns: 'repeat(2, 1fr)', + }, + [theme.breakpoints.down('xs')]: { + gridTemplateColumns: 'repeat(1, 1fr)', + }, + }, +})); + +export const CatalogGraphPage = ({ + relationPairs = RELATION_PAIRS, + initialState, +}: { + relationPairs?: RelationPairs; + initialState?: { + selectedRelations?: string[]; + selectedKinds?: string[]; + rootEntityRefs?: string[]; + maxDepth?: number; + unidirectional?: boolean; + mergeRelations?: boolean; + direction?: Direction; + showFilters?: boolean; + }; +}) => { + const navigate = useNavigate(); + const classes = useStyles(); + const catalogEntityRoute = useRouteRef(catalogEntityRouteRef); + const { + maxDepth, + setMaxDepth, + selectedKinds, + setSelectedKinds, + selectedRelations, + setSelectedRelations, + unidirectional, + setUnidirectional, + mergeRelations, + setMergeRelations, + direction, + setDirection, + rootEntityNames, + setRootEntityNames, + showFilters, + toggleShowFilters, + } = useCatalogGraphPage({ initialState }); + const onNodeClick = useCallback( + (node: EntityNode, event: MouseEvent) => { + const nodeEntityName = parseEntityRef(node.id); + + if (event.shiftKey) { + const path = catalogEntityRoute({ + kind: nodeEntityName.kind.toLowerCase(), + namespace: nodeEntityName.namespace.toLowerCase(), + name: nodeEntityName.name, + }); + navigate(path); + } else { + setRootEntityNames([nodeEntityName]); + } + }, + [catalogEntityRoute, navigate, setRootEntityNames], + ); + + return ( + +
formatEntityRefTitle(e)).join(', ')} + /> + + toggleShowFilters()} + > + Filters + + } + > + + Start tracking your component in by adding it to the software + catalog. + + + + {showFilters && ( + + + + + + + + + )} + + + + Use pinch & zoom to move + around the diagram. Click to change active node, shift click to + navigate to entity. + + 0 + ? selectedKinds + : undefined + } + relations={ + selectedRelations && selectedRelations.length > 0 + ? selectedRelations + : undefined + } + mergeRelations={mergeRelations} + unidirectional={unidirectional} + onNodeClick={onNodeClick} + direction={direction} + relationPairs={relationPairs} + className={classes.graph} + /> + + + + + + ); +}; diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx new file mode 100644 index 0000000000..a3ee8d7e51 --- /dev/null +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx @@ -0,0 +1,47 @@ +/* + * 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 { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { Direction } from '../EntityRelationsGraph'; +import { DirectionFilter } from './DirectionFilter'; + +describe('', () => { + test('should display current value', () => { + const { getByText } = render( + {}} />, + ); + + expect(getByText('Left to right')).toBeInTheDocument(); + }); + + test('should select direction', async () => { + const onChange = jest.fn(); + const { getByText, getByTestId } = render( + , + ); + + expect(getByText('Right to left')).toBeInTheDocument(); + + userEvent.click(getByTestId('select')); + userEvent.click(getByText('Top to bottom')); + + await waitFor(() => { + expect(getByText('Top to bottom')).toBeInTheDocument(); + expect(onChange).toBeCalledWith(Direction.TOP_BOTTOM); + }); + }); +}); diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx new file mode 100644 index 0000000000..815ed1f292 --- /dev/null +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx @@ -0,0 +1,49 @@ +/* + * 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 { Select } from '@backstage/core-components'; +import { Box } from '@material-ui/core'; +import React, { useCallback } from 'react'; +import { Direction } from '../EntityRelationsGraph'; + +const DIRECTION_DISPLAY_NAMES = { + [Direction.LEFT_RIGHT]: 'Left to right', + [Direction.RIGHT_LEFT]: 'Right to left', + [Direction.TOP_BOTTOM]: 'Top to bottom', + [Direction.BOTTOM_TOP]: 'Bottom to top', +}; + +export type Props = { + value: Direction; + onChange: (value: Direction) => void; +}; + +export const DirectionFilter = ({ value, onChange }: Props) => { + const handleChange = useCallback(v => onChange(v as Direction), [onChange]); + + return ( + +