From 30a3afc74b3f48383a18676a232ae5e7c188e77d Mon Sep 17 00:00:00 2001 From: Christian Karidas <105549337+chriskari@users.noreply.github.com> Date: Thu, 12 Dec 2024 11:32:42 +0100 Subject: [PATCH] feat: custom busola extensions (#3523) * feat: add proxyhandler to backend * feat: adjust busola code to handle custom extensions * feat: added example custom extension * feat: rename folder * feat: add docs * feat: update docs * feat: update docs * feat: add more specific types * feat: adjust to review comments * adjust to comments --- backend/common.js | 10 +- backend/index.js | 7 +- backend/proxy.js | 45 ++++ docs/extensibility/README.md | 2 + docs/extensibility/custom-extensions.md | 24 ++ docs/features.md | 9 + examples/custom-extension/README.md | 44 ++++ .../deploy-custom-extension.sh | 4 + examples/custom-extension/general.yaml | 10 + examples/custom-extension/kustomization.yaml | 11 + examples/custom-extension/script.js | 220 ++++++++++++++++++ examples/custom-extension/ui.html | 6 + kyma/environments/dev/config.yaml | 2 + kyma/environments/prod/config.yaml | 2 + kyma/environments/stage/config.yaml | 2 + public/defaultConfig.yaml | 2 + .../Extensibility/ExtensibilityList.js | 42 +++- src/state/navigation/extensionsAtom.ts | 47 ++++ src/state/types.ts | 3 + 19 files changed, 486 insertions(+), 6 deletions(-) create mode 100644 backend/proxy.js create mode 100644 docs/extensibility/custom-extensions.md create mode 100644 examples/custom-extension/README.md create mode 100755 examples/custom-extension/deploy-custom-extension.sh create mode 100644 examples/custom-extension/general.yaml create mode 100644 examples/custom-extension/kustomization.yaml create mode 100644 examples/custom-extension/script.js create mode 100644 examples/custom-extension/ui.html diff --git a/backend/common.js b/backend/common.js index 4a562926e1..0129f0d01b 100644 --- a/backend/common.js +++ b/backend/common.js @@ -142,10 +142,12 @@ function extractHeadersData(req) { const clientCAHeader = 'x-client-certificate-data'; const clientKeyDataHeader = 'x-client-key-data'; const authorizationHeader = 'x-k8s-authorization'; - - const targetApiServer = handleDockerDesktopSubsitution( - new URL(req.headers[urlHeader]), - ); + let targetApiServer; + if (req.headers[urlHeader]) { + targetApiServer = handleDockerDesktopSubsitution( + new URL(req.headers[urlHeader]), + ); + } const ca = decodeHeaderToBuffer(req.headers[caHeader]) || certs; const cert = decodeHeaderToBuffer(req.headers[clientCAHeader]); const key = decodeHeaderToBuffer(req.headers[clientKeyDataHeader]); diff --git a/backend/index.js b/backend/index.js index d98c47589a..82934c016d 100644 --- a/backend/index.js +++ b/backend/index.js @@ -1,4 +1,5 @@ import { makeHandleRequest, serveStaticApp, serveMonaco } from './common'; +import { proxyHandler } from './proxy.js'; import { handleTracking } from './tracking.js'; import jsyaml from 'js-yaml'; //import { requestLogger } from './utils/other'; //uncomment this to log the outgoing traffic @@ -53,6 +54,8 @@ if (process.env.NODE_ENV === 'development') { app.use(cors({ origin: '*' })); } +app.get('/proxy', proxyHandler); + let server = null; if ( @@ -81,13 +84,15 @@ const isDocker = process.env.IS_DOCKER === 'true'; const handleRequest = makeHandleRequest(); if (isDocker) { + // Running in dev mode // yup, order matters here serveMonaco(app); app.use('/backend', handleRequest); serveStaticApp(app, '/', '/core-ui'); } else { + // Running in prod mode handleTracking(app); - app.use(handleRequest); + app.use('/backend', handleRequest); } process.on('SIGINT', function() { diff --git a/backend/proxy.js b/backend/proxy.js new file mode 100644 index 0000000000..ad4a35c94b --- /dev/null +++ b/backend/proxy.js @@ -0,0 +1,45 @@ +import { request as httpsRequest } from 'https'; +import { request as httpRequest } from 'http'; +import { URL } from 'url'; + +async function proxyHandler(req, res) { + const targetUrl = req.query.url; + if (!targetUrl) { + return res.status(400).send('Target URL is required as a query parameter'); + } + + try { + const parsedUrl = new URL(targetUrl); + const isHttps = parsedUrl.protocol === 'https:'; + const libRequest = isHttps ? httpsRequest : httpRequest; + + const options = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || (isHttps ? 443 : 80), + path: parsedUrl.pathname + parsedUrl.search, + method: req.method, + headers: { ...req.headers, host: parsedUrl.host }, + }; + + const proxyReq = libRequest(options, proxyRes => { + // Forward status and headers from the target response + res.writeHead(proxyRes.statusCode, proxyRes.headers); + // Pipe the response data from the target back to the client + proxyRes.pipe(res); + }); + + proxyReq.on('error', () => { + res.status(500).send('An error occurred while making the proxy request.'); + }); + + if (Buffer.isBuffer(req.body)) { + proxyReq.end(req.body); // If the body is already buffered, use it directly. + } else { + req.pipe(proxyReq); // Otherwise, pipe the request for streamed or chunked data. + } + } catch (error) { + res.status(500).send('An error occurred while processing the request.'); + } +} + +export { proxyHandler }; diff --git a/docs/extensibility/README.md b/docs/extensibility/README.md index c3dcc60050..011cea37a2 100644 --- a/docs/extensibility/README.md +++ b/docs/extensibility/README.md @@ -4,6 +4,8 @@ With Busola's extensibility feature, you can create a dedicated user interface (UI) page for your CustomResourceDefinition (CRD). It enables you to add navigation nodes, on cluster or namespace level, and to configure your [UI display](./30-details-summary.md), for example, a resource list page, and details pages. You can also [create and edit forms](./40-form-fields.md). To create a UI component, you need a ConfigMap. +You can also leverage Busola's [custom extension feature](./custom-extensions.md) to design entirely custom user interfaces tailored to your specific needs. + ## Create a ConfigMap for Your UI To create a ConfigMap with your CRD's UI configuration, you can either use the Extensions feature or do it manually. diff --git a/docs/extensibility/custom-extensions.md b/docs/extensibility/custom-extensions.md new file mode 100644 index 0000000000..ef645b8a57 --- /dev/null +++ b/docs/extensibility/custom-extensions.md @@ -0,0 +1,24 @@ +# Custom Extensions + +Busola's custom extension feature allows you to design fully custom user interfaces beyond the built-in extensibility functionality. This feature is ideal for creating unique and specialized displays not covered by the built-in components. + +## Getting Started + +To enable the custom extension feature, you must set the corresponding feature flag in your Busola config, which is disabled by default. + +```yaml +EXTENSIBILITY_CUSTOM_COMPONENTS: + isEnabled: true +``` + +## Creating Custom Extensions + +Creating a custom extension is as straightforward as setting up a ConfigMap with the following sections: + +- `data.general`: Contains configuration details +- `data.customHtml`: Defines static HTML content +- `data.customScript`: Adds dynamic behavior to your extension. + +Once your ConfigMap is ready, add it to your cluster, and Busola will load and display your custom UI. + +See this [example](./../../examples/custom-extension/README.md), to learn more. diff --git a/docs/features.md b/docs/features.md index 91283979ae..6878f63f0b 100644 --- a/docs/features.md +++ b/docs/features.md @@ -56,6 +56,15 @@ EXTENSIBILITY: isEnabled: true ``` +- **EXTENSIBILITY_CUSTOM_COMPONENTS** - is used to indicate whether entirely custom extensions can be added to Busola. See [this example](../examples/custom-extension/README.md). + +Default settings: + +```yaml +EXTENSIBILITY_CUSTOM_COMPONENTS: + isEnabled: false +``` + - **EXTERNAL_NODES** - a list of links to external websites. `category`: a category name, `icon`: an optional icon, `scope`: either `namespace` or `cluster` (defaults to `cluster`), `children`: a list of pairs (label and link). Default settings: diff --git a/examples/custom-extension/README.md b/examples/custom-extension/README.md new file mode 100644 index 0000000000..9f3e7c7c35 --- /dev/null +++ b/examples/custom-extension/README.md @@ -0,0 +1,44 @@ +# Set Up Your Custom Busola Extension + +This example contains a basic custom extension that queries all deployments of a selected namespace of your cluster. Additionally, it retrieves the current weather data for Munich, Germany, from an external weather API. + +To set up and deploy your own custom Busola extension, follow these steps. + +1. Adjust the static HTML content. + +Edit the `ui.html` file to define the static HTML content for your custom extension. + +2. Configure dynamic components. + +Set up dynamic or behavioral components by modifying the custom element defined in the `script.js` file. + +- **Accessing Kubernetes resources**: Use the `fetchWrapper` function to interact with cluster resources through the Kubernetes API. + +- **Making external API requests**: Use the `proxyFetch` function to handle requests to external APIs that are subject to CORS regulations. + +3. Define extension metadata + +Update the `general.yaml` file to define metadata for your custom extension. + +> [! WARNING] +> Ensure that the `general.customElement` property matches the name of the custom element defined in `script.js`. The script is loaded only once, and this property is used to determine whether the custom element is already defined. + +4. Deploy your extension + +Before running the deployment command, ensure that your `kubeconfig` is correctly exported and points to the desired cluster. You can check the current context by running: + +```bash +kubectl config current-context +``` + +Run `./deploy-custom-extension.sh` to create a ConfigMap and deploy it to your cluster + +Alternatively, you can use the following command: + +```bash +kubectl kustomize . | kubectl apply -n kyma-system -f - +``` + +### 5. Test your changes locally + +Run `npm start` to start the development server. diff --git a/examples/custom-extension/deploy-custom-extension.sh b/examples/custom-extension/deploy-custom-extension.sh new file mode 100755 index 0000000000..8861b96b68 --- /dev/null +++ b/examples/custom-extension/deploy-custom-extension.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +kubectl kustomize . > ./custom-ui.yaml +kubectl apply -f ./custom-ui.yaml -n kyma-system diff --git a/examples/custom-extension/general.yaml b/examples/custom-extension/general.yaml new file mode 100644 index 0000000000..fce5e5daeb --- /dev/null +++ b/examples/custom-extension/general.yaml @@ -0,0 +1,10 @@ +resource: + kind: Secret + version: v1 +urlPath: custom-busola-extension-example +category: Kyma +name: Custom busola extension example +scope: cluster +customElement: my-custom-element +description: >- + Custom busola extension example diff --git a/examples/custom-extension/kustomization.yaml b/examples/custom-extension/kustomization.yaml new file mode 100644 index 0000000000..331b64b818 --- /dev/null +++ b/examples/custom-extension/kustomization.yaml @@ -0,0 +1,11 @@ +configMapGenerator: + - name: custom-ui + files: + - customHtml=ui.html + - customScript=script.js + - general=general.yaml + options: + disableNameSuffixHash: true + labels: + busola.io/extension: 'resource' + busola.io/extension-version: '0.5' diff --git a/examples/custom-extension/script.js b/examples/custom-extension/script.js new file mode 100644 index 0000000000..bc3c6bd601 --- /dev/null +++ b/examples/custom-extension/script.js @@ -0,0 +1,220 @@ +function fetchWrapper(url, options = {}) { + if (window.extensionProps?.kymaFetchFn) { + return window.extensionProps.kymaFetchFn(url, options); + } + return fetch(url, options); +} + +function proxyFetch(url, options = {}) { + const baseUrl = window.location.hostname.startsWith('localhost') + ? 'http://localhost:3001/proxy' + : '/proxy'; + const encodedUrl = encodeURIComponent(url); + const proxyUrl = `${baseUrl}?url=${encodedUrl}`; + return fetch(proxyUrl, options); +} + +class MyCustomElement extends HTMLElement { + connectedCallback() { + const shadow = this.attachShadow({ mode: 'open' }); + + // Add basic styling + const style = document.createElement('style'); + style.textContent = ` + .container { + padding: 1rem;lu + } + .deployments-list { + margin-top: 1rem; + } + .deployment-item { + padding: 0.5rem; + margin: 0.5rem 0; + background: #f5f5f5; + border-radius: 4px; + } + .weather-container { + margin-top: 2rem; + padding: 1rem; + background: #e0f7fa; + border-radius: 8px; + } + .weather-item { + padding: 0.5rem 0; + margin: 0.5rem 0; + font-size: 1rem; + } + `; + shadow.appendChild(style); + + // Create container + const container = document.createElement('div'); + container.className = 'container'; + + // Create namespace dropdown + const namespaceSelect = document.createElement('ui5-select'); + namespaceSelect.id = 'namespaceSelect'; + container.appendChild(namespaceSelect); + + // Create deployments container + const deploymentsList = document.createElement('div'); + deploymentsList.className = 'deployments-list'; + container.appendChild(deploymentsList); + + // Create weather container + const weatherContainer = document.createElement('div'); + weatherContainer.className = 'weather-container'; + weatherContainer.id = 'weatherContainer'; + container.appendChild(weatherContainer); + + shadow.appendChild(container); + + // Load initial data + this.loadData(namespaceSelect, deploymentsList); + + // Add change listener + namespaceSelect.addEventListener('change', () => { + this.updateDeploymentsList(namespaceSelect.value, deploymentsList); + }); + + // Fetch and update weather data + fetchMunichWeatherData().then(weatherData => { + this.updateWeatherUI(weatherData, weatherContainer); + }); + } + + async loadData(namespaceSelect, deploymentsList) { + try { + // Get namespaces + const namespaces = await getNamespaces(); + + // Populate namespace dropdown + namespaces.forEach(namespace => { + const option = document.createElement('ui5-option'); + option.value = namespace.metadata.name; + option.innerHTML = namespace.metadata.name; + namespaceSelect.appendChild(option); + }); + + // Load deployments for first namespace + if (namespaces.length > 0) { + this.updateDeploymentsList( + namespaces[0].metadata.name, + deploymentsList, + ); + } + } catch (error) { + console.error('Failed to load data:', error); + } + } + + async updateDeploymentsList(namespace, deploymentsList) { + try { + const deployments = await getDeployments(namespace); + + // Clear current list + deploymentsList.innerHTML = ''; + + // Add deployment to list + deployments.forEach(deployment => { + const deploymentItem = document.createElement('div'); + deploymentItem.className = 'deployment-item'; + deploymentItem.innerHTML = ` +