From 195652d06db09eefa63638d0bac848eb762b54ca Mon Sep 17 00:00:00 2001 From: yolossn Date: Mon, 28 Oct 2024 19:14:03 +0530 Subject: [PATCH] feat(kubernetes): add headlamp dashboard formatter this patch adds formatter for Headlamp to the kubernetes-react plugin. Signed-off-by: yolossn --- plugins/kubernetes-react/report.api.md | 6 + .../HeadlampClusterLinksFormatter.test.ts | 83 +++++++++++ .../HeadlampClusterLinksFormatter.ts | 136 ++++++++++++++++++ .../src/api/formatters/index.ts | 3 + 4 files changed, 228 insertions(+) create mode 100644 plugins/kubernetes-react/src/api/formatters/HeadlampClusterLinksFormatter.test.ts create mode 100644 plugins/kubernetes-react/src/api/formatters/HeadlampClusterLinksFormatter.ts diff --git a/plugins/kubernetes-react/report.api.md b/plugins/kubernetes-react/report.api.md index 3097d6ad22..8260b61a94 100644 --- a/plugins/kubernetes-react/report.api.md +++ b/plugins/kubernetes-react/report.api.md @@ -283,6 +283,12 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { // @public (undocumented) export const GroupedResponsesContext: Context; +// @public (undocumented) +export class HeadlampClusterLinksFormatter implements ClusterLinksFormatter { + // (undocumented) + formatClusterLink(options: ClusterLinksFormatterOptions): Promise; +} + // @public (undocumented) export const HorizontalPodAutoscalerDrawer: (props: { hpa: V2HorizontalPodAutoscaler; diff --git a/plugins/kubernetes-react/src/api/formatters/HeadlampClusterLinksFormatter.test.ts b/plugins/kubernetes-react/src/api/formatters/HeadlampClusterLinksFormatter.test.ts new file mode 100644 index 0000000000..533e966137 --- /dev/null +++ b/plugins/kubernetes-react/src/api/formatters/HeadlampClusterLinksFormatter.test.ts @@ -0,0 +1,83 @@ +/* + * 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 { HeadlampClusterLinksFormatter } from './HeadlampClusterLinksFormatter'; +import { ClusterLinksFormatterOptions } from '../../types'; + +describe('HeadlampClusterLinksFormatter', () => { + let formatter: HeadlampClusterLinksFormatter; + + beforeEach(() => { + formatter = new HeadlampClusterLinksFormatter(); + // Mock window.location.origin + Object.defineProperty(window, 'location', { + value: { origin: 'http://localhost:3000' }, + writable: true, + }); + }); + + it('formats internal dashboard link correctly', async () => { + const options: ClusterLinksFormatterOptions = { + dashboardParameters: { + internal: true, + headlampRoute: '/headlamp', + clusterName: 'test-cluster', + }, + object: { metadata: { name: 'test-pod', namespace: 'default' } }, + kind: 'Pod', + }; + + const result = await formatter.formatClusterLink(options); + expect(result.toString()).toBe( + 'http://localhost:3000/headlamp?to=%2Fc%2Ftest-cluster%2Fpods%2Fdefault%2Ftest-pod', + ); + }); + + it('formats external dashboard link correctly', async () => { + const options: ClusterLinksFormatterOptions = { + dashboardUrl: new URL('https://headlamp.com'), + object: { metadata: { name: 'test-deployment', namespace: 'default' } }, + kind: 'Deployment', + }; + + const result = await formatter.formatClusterLink(options); + expect(result.toString()).toBe( + 'https://headlamp.com/?to=%2Fc%2Fdefault%2Fdeployments%2Fdefault%2Ftest-deployment', + ); + }); + + it('throws error when dashboard URL is missing for external dashboard', async () => { + const options: ClusterLinksFormatterOptions = { + object: { metadata: { name: 'test-service', namespace: 'default' } }, + kind: 'Service', + }; + + await expect(formatter.formatClusterLink(options)).rejects.toThrow( + 'Dashboard URL is required or dashboardInternal must be true', + ); + }); + + it('throws error for unsupported kind', async () => { + const options: ClusterLinksFormatterOptions = { + dashboardParameters: { internal: true }, + object: { metadata: { name: 'test-unknown', namespace: 'default' } }, + kind: 'UnknownKind', + }; + + await expect(formatter.formatClusterLink(options)).rejects.toThrow( + 'Could not find path for kind: UnknownKind', + ); + }); +}); diff --git a/plugins/kubernetes-react/src/api/formatters/HeadlampClusterLinksFormatter.ts b/plugins/kubernetes-react/src/api/formatters/HeadlampClusterLinksFormatter.ts new file mode 100644 index 0000000000..c48566dd2b --- /dev/null +++ b/plugins/kubernetes-react/src/api/formatters/HeadlampClusterLinksFormatter.ts @@ -0,0 +1,136 @@ +/* + * 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, + ClusterLinksFormatterOptions, +} from '../../types'; + +/** @public */ +export class HeadlampClusterLinksFormatter implements ClusterLinksFormatter { + async formatClusterLink(options: ClusterLinksFormatterOptions): Promise { + const { dashboardUrl, dashboardParameters, object, kind } = options; + + if (!dashboardUrl && !dashboardParameters?.internal) { + throw new Error( + 'Dashboard URL is required or dashboardInternal must be true', + ); + } + + const clusterName = + (dashboardParameters?.clusterName as string) ?? 'default'; + const path = this.getHeadlampPath(kind, object, clusterName); + if (!path) { + throw new Error(`Could not find path for kind: ${kind}`); + } + + let baseUrl: URL; + + if (dashboardParameters?.internal) { + baseUrl = new URL(window.location.origin); + baseUrl.pathname = + (dashboardParameters.headlampRoute as string) || '/headlamp'; + } else { + if (!dashboardUrl?.href) { + throw new Error( + 'Dashboard URL is required when not using internal dashboard', + ); + } + baseUrl = new URL(dashboardUrl.href); + } + + baseUrl.searchParams.set('to', path); + return baseUrl; + } + + private readonly NAMESPACED_RESOURCES = new Set([ + 'pod', + 'deployment', + 'replicaset', + 'statefulset', + 'daemonset', + 'job', + 'cronjob', + 'service', + 'ingress', + 'configmap', + 'secret', + 'serviceaccount', + 'role', + 'rolebinding', + 'networkpolicy', + 'horizontalpodautoscaler', + 'poddisruptionbudget', + 'persistentvolumeclaim', + ]); + + private getHeadlampPath( + kind: string, + object: { + metadata?: { + name?: string; + namespace?: string; + }; + }, + clusterName: string, + ): string { + const lowercaseKind = kind.toLocaleLowerCase('en-US'); + const { name } = object.metadata ?? {}; + let { namespace } = object.metadata ?? {}; + + if (!name) { + throw new Error(`Resource name is required for kind: ${kind}`); + } + + // Add namespace validation + if (this.NAMESPACED_RESOURCES.has(lowercaseKind) && !namespace) { + throw new Error(`Namespace is required for namespaced resource: ${kind}`); + } + if (!namespace) { + namespace = 'default'; + } + + const pathMap: Record = { + namespace: `/c/${clusterName}/namespaces/${name}`, + node: `/c/${clusterName}/nodes/${name}`, + persistentvolume: `/c/${clusterName}/storage/persistentvolumes/${name}`, + persistentvolumeclaim: `/c/${clusterName}/storage/persistentvolumeclaims/${namespace}/${name}`, + pod: `/c/${clusterName}/pods/${namespace}/${name}`, + deployment: `/c/${clusterName}/deployments/${namespace}/${name}`, + replicaset: `/c/${clusterName}/replicasets/${namespace}/${name}`, + statefulset: `/c/${clusterName}/statefulsets/${namespace}/${name}`, + daemonset: `/c/${clusterName}/daemonsets/${namespace}/${name}`, + job: `/c/${clusterName}/jobs/${namespace}/${name}`, + cronjob: `/c/${clusterName}/cronjobs/${namespace}/${name}`, + service: `/c/${clusterName}/services/${namespace}/${name}`, + ingress: `/c/${clusterName}/ingresses/${namespace}/${name}`, + configmap: `/c/${clusterName}/configmaps/${namespace}/${name}`, + secret: `/c/${clusterName}/secrets/${namespace}/${name}`, + serviceaccount: `/c/${clusterName}/serviceaccounts/${namespace}/${name}`, + role: `/c/${clusterName}/roles/${namespace}/${name}`, + rolebinding: `/c/${clusterName}/rolebindings/${namespace}/${name}`, + clusterrole: `/c/${clusterName}/clusterroles/${name}`, + clusterrolebinding: `/c/${clusterName}/clusterrolebindings/${name}`, + storageclass: `/c/${clusterName}/storage/storageclasses/${name}`, + networkpolicy: `/c/${clusterName}/networkpolicies/${namespace}/${name}`, + horizontalpodautoscaler: `/c/${clusterName}/horizontalpodautoscalers/${namespace}/${name}`, + poddisruptionbudget: `/c/${clusterName}/poddisruptionbudgets/${namespace}/${name}`, + customresourcedefinition: `/c/${clusterName}/customresourcedefinitions/${name}`, + }; + + return pathMap[lowercaseKind] ?? ''; + } +} diff --git a/plugins/kubernetes-react/src/api/formatters/index.ts b/plugins/kubernetes-react/src/api/formatters/index.ts index c4bf52d181..76cff5a48f 100644 --- a/plugins/kubernetes-react/src/api/formatters/index.ts +++ b/plugins/kubernetes-react/src/api/formatters/index.ts @@ -21,6 +21,7 @@ import { GkeClusterLinksFormatter } from './GkeClusterLinksFormatter'; import { StandardClusterLinksFormatter } from './StandardClusterLinksFormatter'; import { OpenshiftClusterLinksFormatter } from './OpenshiftClusterLinksFormatter'; import { RancherClusterLinksFormatter } from './RancherClusterLinksFormatter'; +import { HeadlampClusterLinksFormatter } from './HeadlampClusterLinksFormatter'; import { ProfileInfoApi } from '@backstage/core-plugin-api'; export { @@ -30,6 +31,7 @@ export { GkeClusterLinksFormatter, OpenshiftClusterLinksFormatter, RancherClusterLinksFormatter, + HeadlampClusterLinksFormatter, }; /** @public */ @@ -46,5 +48,6 @@ export function getDefaultFormatters(deps: { gke: new GkeClusterLinksFormatter(deps.googleAuthApi), openshift: new OpenshiftClusterLinksFormatter(), rancher: new RancherClusterLinksFormatter(), + headlamp: new HeadlampClusterLinksFormatter(), }; }