Merge pull request #29570 from yolossn/headlamp-formatter

feat(kubernetes): add headlamp dashboard formatter
This commit is contained in:
Vincenzo Scamporlino
2025-04-23 22:32:41 +02:00
committed by GitHub
6 changed files with 269 additions and 2 deletions
@@ -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',
);
});
});
@@ -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<URL> {
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<string, string> = {
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] ?? '';
}
}
@@ -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(),
};
}