diff --git a/.github/actions/rebase/action.yml b/.github/actions/rebase/action.yml new file mode 100644 index 0000000000..4e6d0cec0b --- /dev/null +++ b/.github/actions/rebase/action.yml @@ -0,0 +1,36 @@ +name: 'Rebase' +description: 'Action for rebasing to the main branch' + +runs: + using: 'composite' + steps: + # Setup identity to avoid errors when git is trying to rebase without identity set + - name: Setup git identity + run: | + git config --global user.email "rebase@action.com" + git config --global user.name "Rebase Action" + shell: bash + + # Update origin/main branch locally to cover case when repo is not fully cloned + # for example when using `actions/checkout@v4` action with default `fetch-depth` value (1) + # read more: https://github.com/actions/checkout?tab=readme-ov-file#usage + - name: Update origin/main + run: | + git fetch origin main + git checkout origin/main + git checkout -B main + git pull --unshallow origin main + + # go back to the HEAD commit + git switch --detach ${{ github.sha }} + shell: bash + + - name: Rebase to main + run: | + git rebase main + shell: bash + + - name: Print branch log + run: | + git log origin/main~1.. + shell: bash diff --git a/.github/workflows/accessibility-tests.yml b/.github/workflows/accessibility-tests.yml index b0ecc60536..78b66b255f 100644 --- a/.github/workflows/accessibility-tests.yml +++ b/.github/workflows/accessibility-tests.yml @@ -3,6 +3,12 @@ name: Accessibility Tests on: schedule: - cron: "0 6 * * 1" + pull_request_target: + types: [opened, edited, synchronize, reopened, ready_for_review] + paths: + - ".github/workflows/accessibility-tests.yml" + - "tests/integration/tests/accessibility/**" + - "tests/integration/support/**" jobs: run-accessibility-tests: diff --git a/.github/workflows/busola-backend-build.yml b/.github/workflows/busola-backend-build.yml index 35186b5bae..f7f0619bba 100644 --- a/.github/workflows/busola-backend-build.yml +++ b/.github/workflows/busola-backend-build.yml @@ -22,6 +22,7 @@ permissions: jobs: build-backend-image: uses: kyma-project/test-infra/.github/workflows/image-builder.yml@main # Usage: kyma-project/test-infra/.github/workflows/image-builder.yml@main + if: github.event.pull_request.draft == false with: name: busola-backend dockerfile: Dockerfile diff --git a/.github/workflows/busola-build.yml b/.github/workflows/busola-build.yml index 4e490b0b11..9400d3f090 100644 --- a/.github/workflows/busola-build.yml +++ b/.github/workflows/busola-build.yml @@ -34,6 +34,7 @@ permissions: jobs: build-busola-image: uses: kyma-project/test-infra/.github/workflows/image-builder.yml@main # Usage: kyma-project/test-infra/.github/workflows/image-builder.yml@main + if: github.event.pull_request.draft == false with: name: busola dockerfile: Dockerfile diff --git a/.github/workflows/busola-web-build.yml b/.github/workflows/busola-web-build.yml index c3f60c28f3..50bf2ca2c6 100644 --- a/.github/workflows/busola-web-build.yml +++ b/.github/workflows/busola-web-build.yml @@ -33,6 +33,7 @@ permissions: jobs: build-web-image: uses: kyma-project/test-infra/.github/workflows/image-builder.yml@main # Usage: kyma-project/test-infra/.github/workflows/image-builder.yml@main + if: github.event.pull_request.draft == false with: name: busola-web dockerfile: Dockerfile.web diff --git a/.github/workflows/lint-check-pr.yml b/.github/workflows/lint-check-pr.yml index 47e5f6da83..6bec9a1185 100644 --- a/.github/workflows/lint-check-pr.yml +++ b/.github/workflows/lint-check-pr.yml @@ -7,6 +7,7 @@ on: jobs: run-lint-check: runs-on: ubuntu-latest + if: github.event.pull_request.draft == false steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/pull-integration-cluster-k3d.yml b/.github/workflows/pull-integration-cluster-k3d.yml index 54535e08c4..5b0180e2fa 100644 --- a/.github/workflows/pull-integration-cluster-k3d.yml +++ b/.github/workflows/pull-integration-cluster-k3d.yml @@ -9,17 +9,18 @@ on: - "tests/**" - "nginx/**" - "src/**" + - "backend/**" jobs: run-cluster-test: runs-on: ubuntu-latest + if: github.event.pull_request.draft == false steps: - uses: gardenlinux/workflow-telemetry-action@v2 with: comment_on_pr: false - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - uses: ./.github/actions/rebase - name: Create Single Cluster uses: AbsaOSS/k3d-action@4e8b3239042be1dc0aed6c5eb80c13b18200fc79 #v2.4.0 with: diff --git a/.github/workflows/pull-integration-namespace-k3d.yml b/.github/workflows/pull-integration-namespace-k3d.yml index 7d944a5fbe..1359c8c519 100644 --- a/.github/workflows/pull-integration-namespace-k3d.yml +++ b/.github/workflows/pull-integration-namespace-k3d.yml @@ -9,17 +9,18 @@ on: - "tests/**" - "nginx/**" - "src/**" + - "backend/**" jobs: run-namespace-test: runs-on: ubuntu-latest + if: github.event.pull_request.draft == false steps: - uses: gardenlinux/workflow-telemetry-action@v2 with: comment_on_pr: false - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - uses: ./.github/actions/rebase - name: Create Single Cluster uses: AbsaOSS/k3d-action@4e8b3239042be1dc0aed6c5eb80c13b18200fc79 #v2.4.0 with: diff --git a/.github/workflows/pull-kyma-integration-tests.yml b/.github/workflows/pull-kyma-integration-tests.yml index 71704fc859..60fc82f6bc 100644 --- a/.github/workflows/pull-kyma-integration-tests.yml +++ b/.github/workflows/pull-kyma-integration-tests.yml @@ -9,18 +9,19 @@ on: - "tests/integration/**" - "nginx/**" - "src/**" + - "backend/**" - "kyma/**" - "Dockerfile*" jobs: run-integration-test: runs-on: ubuntu-latest + if: github.event.pull_request.draft == false steps: - uses: gardenlinux/workflow-telemetry-action@v2 with: comment_on_pr: false - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - uses: ./.github/actions/rebase - name: Install k3d env: K3D_URL: https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh diff --git a/.github/workflows/pull-lighthouse.yml b/.github/workflows/pull-lighthouse.yml index a68193dc12..d1d71ccf07 100644 --- a/.github/workflows/pull-lighthouse.yml +++ b/.github/workflows/pull-lighthouse.yml @@ -13,13 +13,13 @@ on: jobs: run-lighthouse-test: runs-on: ubuntu-latest + if: github.event.pull_request.draft == false steps: - uses: gardenlinux/workflow-telemetry-action@v2 with: comment_on_pr: false - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - uses: ./.github/actions/rebase - name: Create Single Cluster uses: AbsaOSS/k3d-action@4e8b3239042be1dc0aed6c5eb80c13b18200fc79 #v2.4.0 with: diff --git a/.github/workflows/pull-smoke-test-prod.yml b/.github/workflows/pull-smoke-test-prod.yml index 64020107a7..9ba14d5f26 100644 --- a/.github/workflows/pull-smoke-test-prod.yml +++ b/.github/workflows/pull-smoke-test-prod.yml @@ -15,13 +15,13 @@ on: jobs: run-smoke-test-prod: runs-on: ubuntu-latest + if: github.event.pull_request.draft == false steps: - uses: gardenlinux/workflow-telemetry-action@v2 with: comment_on_pr: false - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - uses: ./.github/actions/rebase - name: Install k3d env: K3D_URL: https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh diff --git a/.github/workflows/pull-smoke-test-stage.yml b/.github/workflows/pull-smoke-test-stage.yml index 5198cdebbe..0d1b6d4015 100644 --- a/.github/workflows/pull-smoke-test-stage.yml +++ b/.github/workflows/pull-smoke-test-stage.yml @@ -15,13 +15,13 @@ on: jobs: run-smoke-test-stage: runs-on: ubuntu-latest + if: github.event.pull_request.draft == false steps: - uses: gardenlinux/workflow-telemetry-action@v2 with: comment_on_pr: false - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - uses: ./.github/actions/rebase - name: Install k3d env: K3D_URL: https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh diff --git a/.github/workflows/pull-unit-tests.yml b/.github/workflows/pull-unit-tests.yml index 4038d1ae7d..c233864f01 100644 --- a/.github/workflows/pull-unit-tests.yml +++ b/.github/workflows/pull-unit-tests.yml @@ -12,13 +12,13 @@ on: jobs: run-unit-test: runs-on: ubuntu-latest + if: github.event.pull_request.draft == false steps: - uses: gardenlinux/workflow-telemetry-action@v2 with: comment_on_pr: false - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - uses: ./.github/actions/rebase - uses: actions/setup-node@v4 with: node-version: 20 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..8c2b4e3e2c 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.use('/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/package-lock.json b/backend/package-lock.json index a0477a86ad..d8cfcc0359 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,7 +12,7 @@ "@babel/runtime": "^7.13.10", "compression": "^1.7.4", "cors": "^2.8.5", - "express": "^4.21.0", + "express": "^4.21.2", "https": "^1.0.0", "jose": "^5.2.4", "js-yaml": "^4.1.0", @@ -4481,9 +4481,9 @@ } }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "engines": { "node": ">= 0.6" } @@ -5174,16 +5174,16 @@ } }, "node_modules/express": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", - "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -5197,7 +5197,7 @@ "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", @@ -5212,6 +5212,10 @@ }, "engines": { "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express/node_modules/safe-buffer": { @@ -9233,9 +9237,9 @@ "dev": true }, "node_modules/path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" }, "node_modules/picocolors": { "version": "1.1.0", @@ -14511,9 +14515,9 @@ } }, "cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==" }, "cookie-signature": { "version": "1.0.6", @@ -15019,16 +15023,16 @@ } }, "express": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", - "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "requires": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -15042,7 +15046,7 @@ "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", @@ -18048,9 +18052,9 @@ "dev": true }, "path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" }, "picocolors": { "version": "1.1.0", diff --git a/backend/package.json b/backend/package.json index 42fb1cc89c..a56ca31360 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,7 +16,7 @@ "@babel/runtime": "^7.13.10", "compression": "^1.7.4", "cors": "^2.8.5", - "express": "^4.21.0", + "express": "^4.21.2", "https": "^1.0.0", "jose": "^5.2.4", "js-yaml": "^4.1.0", 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/contributor/testing-strategy.md b/docs/contributor/testing-strategy.md index 6ee09aa3d3..113ef87f1b 100644 --- a/docs/contributor/testing-strategy.md +++ b/docs/contributor/testing-strategy.md @@ -2,19 +2,27 @@ Each pull request (PR) to the repository triggers CI/CD jobs that verify the Busola configuration, build and run integration tests. -- `pre-busola-web-deployment-check` - Checks if the Busola web image in deployment is bumped correctly. -- `pre-busola-backend-deployment-check` - Checks if the Busola backend image in deployment is bumped correctly. The check runs only when PR changes affect the backend. -- `pull-busola-web-build` - Unit tests of Busola checking the ESLint code quality, and building the web Docker image. -- `pull-busola-local-build` - Unit tests of Busola checking the ESLint code quality and building the web and backend Docker image. -- `pull-busola-backend-build` - Builds the backend Docker image. The job runs only when changes affect the backend. -- `pull-busola-integration-cluster-k3d` - Performs integration testing for Busola related to cluster-level functionalities using a k3d cluster. -- `pull-busola-integration-namespace-k3d` - Performs integration testing for Busola related to namespace-level functionalities using a k3d cluster. -- `pull-busola-lighthouse` - Performs performance testing for Busola - threshold for accessibility: 80, best-practices: 100. +- `Busola Web Build / build-web-image / Build image` - Checks the ESLint code quality and builds the web Docker image. +- `Busola Build / build-busola-image / Build image` - Checks the ESLint code quality and builds the web and backend Docker image. +- `Busola Backend Build / build-backend-image / Build image` - Builds the backend Docker image. The job runs only when changes affect the backend. +- `PR Integration Cluster Tests / run-cluster-test` - Performs integration testing for Busola related to cluster-level functionalities using a k3d cluster. +- `PR Integration Namespace Tests / run-namespace-test` - Performs integration testing for Busola related to namespace-level functionalities using a k3d cluster. +- `PR Kyma Dashboard Integration Tests Dev / run-integration-test` - Performs integration testing for Busola with DEV environement and configuration related to the Kyma functionalities using a k3d cluster with installed Kyma. +- `PR Kyma Dashboard Smoke Tests Stage / run-smoke-test-stage` - Performs smoke testing for Busola with STAGE environement and configuration related to the Kyma functionalities using a k3d cluster with installed Kyma. +- `PR Kyma Dashboard Smoke Tests Prod / run-smoke-test-prod` - Performs smoke testing for Busola with PROD environement and configuration related to the Kyma functionalities using a k3d cluster with installed Kyma. +- `PR Lighthouse Test / run-lighthouse-test` - Performs performance testing for Busola - threshold for accessibility: 80, best-practices: 100. +- `PR Lint Check / run-lint-check` - Performing ESlint and Prettier code quality. +- `PR Unit Tests / run-unit-test` - Performs unit tests of the Busola. - `Lint Markdown Links PR / markdown-link-check` - Checks links in documentation. - `CodeQL / Analyze (javascript)` - Code quality static code check. After the pull request is merged, the following CI/CD jobs are executed: -- `post-busola-web-build` - Performs Busola unit tests and builds the web Docker image. -- `post-busola-local-build` - Performs Busola unit tests and builds the web and backend Docker image. -- `post-busola-backend-build` - Builds the backend Docker image. +- `Busola Web Build / build-web-image / Build image` - Performs Busola unit tests and builds the web Docker image. +- `Busola Build / build-busola-image / Build image` - Performs Busola unit tests and builds the web and backend Docker image. +- `Busola Backend Build / build-backend-image / Build image` - Builds the backend Docker image. +- `CodeQL / Analyze (javascript)` - Code quality static code check. + +Following CI/CD jobs are executed once a week: + +- `Accessibility Tests - run-accessibility-tests` - Performs accessibility tests of the Busola using k3d cluster with Kyma installed. 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 = ` +
Name: ${deployment.metadata.name}
+ `; + deploymentsList.appendChild(deploymentItem); + }); + + // Show message if no deployments + if (deployments.length === 0) { + const messageStrip = document.createElement('ui5-message-strip'); + messageStrip.innerHTML = 'No deployments found in this namespace'; + + deploymentsList.innerHTML = messageStrip.outerHTML; + } + } catch (error) { + console.error('Failed to update deployments:', error); + deploymentsList.innerHTML = '
Error loading deployments
'; + } + } + + async updateWeatherUI(weatherData, weatherContainer) { + const { temperature, condition } = weatherData; + weatherContainer.innerHTML = ` + Current weather in Munich: +
Temperature: ${temperature}°C
+
Condition: ${condition}
+ `; + } +} + +async function getNamespaces() { + const resp = await fetchWrapper('/api/v1/namespaces'); + const data = await resp.json(); + return data.items; +} + +async function getDeployments(namespace) { + const resp = await fetchWrapper( + `/apis/apps/v1/namespaces/${namespace}/deployments`, + ); + const data = await resp.json(); + return data.items; +} + +async function fetchMunichWeatherData() { + const latitude = 48.1351; + const longitude = 11.582; + const url = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t_weather=true`; + + const response = await proxyFetch(url); + if (!response.ok) { + console.error(`Error fetching weather: ${response.status}`); + return; + } + const data = await response.json(); + + const currentWeather = data.current_weather; + const temperature = currentWeather.temperature; + const weatherCode = currentWeather.weathercode; + + const weatherConditions = { + 0: 'Clear sky', + 1: 'Mainly clear', + 2: 'Partly cloudy', + 3: 'Overcast', + 45: 'Fog', + 48: 'Depositing rime fog', + 51: 'Light drizzle', + 53: 'Moderate drizzle', + 55: 'Dense drizzle', + 56: 'Light freezing drizzle', + 57: 'Dense freezing drizzle', + 61: 'Slight rain', + 63: 'Moderate rain', + 65: 'Heavy rain', + 66: 'Light freezing rain', + 67: 'Heavy freezing rain', + 71: 'Slight snow fall', + 73: 'Moderate snow fall', + 75: 'Heavy snow fall', + 77: 'Snow grains', + 80: 'Slight rain showers', + 81: 'Moderate rain showers', + 82: 'Violent rain showers', + 85: 'Slight snow showers', + 86: 'Heavy snow showers', + 95: 'Thunderstorm', + 96: 'Thunderstorm with slight hail', + 99: 'Thunderstorm with heavy hail', + }; + + const condition = + weatherConditions[weatherCode] || 'Unknown weather condition'; + + return { temperature, condition }; +} + +if (!customElements.get('my-custom-element')) { + customElements.define('my-custom-element', MyCustomElement); +} diff --git a/examples/custom-extension/ui.html b/examples/custom-extension/ui.html new file mode 100644 index 0000000000..7f2d43e838 --- /dev/null +++ b/examples/custom-extension/ui.html @@ -0,0 +1,6 @@ +
+ + Deployments in Namespace + + +
diff --git a/index.html b/index.html index fcd8962a7f..12afa0bd60 100644 --- a/index.html +++ b/index.html @@ -19,6 +19,10 @@ content="width=device-width, initial-scale=1, shrink-to-fit=no" /> + Busola diff --git a/kyma/environments/dev/config.yaml b/kyma/environments/dev/config.yaml index a5651671db..a29e6a3976 100644 --- a/kyma/environments/dev/config.yaml +++ b/kyma/environments/dev/config.yaml @@ -66,6 +66,8 @@ config: isEnabled: true EXTENSIBILITY_INJECTIONS: isEnabled: true + EXTENSIBILITY_CUSTOM_COMPONENTS: + isEnabled: false EXTENSIBILITY_WIZARD: isEnabled: true TRACKING: diff --git a/kyma/environments/prod/config.yaml b/kyma/environments/prod/config.yaml index 6c570ccd12..2b239ac776 100644 --- a/kyma/environments/prod/config.yaml +++ b/kyma/environments/prod/config.yaml @@ -68,6 +68,8 @@ config: isEnabled: true EXTENSIBILITY_INJECTIONS: isEnabled: true + EXTENSIBILITY_CUSTOM_COMPONENTS: + isEnabled: false EXTENSIBILITY_WIZARD: isEnabled: true EVENTING: diff --git a/kyma/environments/stage/config.yaml b/kyma/environments/stage/config.yaml index 8de69d54aa..a1bf90088b 100644 --- a/kyma/environments/stage/config.yaml +++ b/kyma/environments/stage/config.yaml @@ -66,6 +66,8 @@ config: isEnabled: true EXTENSIBILITY_INJECTIONS: isEnabled: true + EXTENSIBILITY_CUSTOM_COMPONENTS: + isEnabled: false EXTENSIBILITY_WIZARD: isEnabled: true EVENTING: diff --git a/kyma/extensions/kyma/kyma.yaml b/kyma/extensions/kyma/kyma.yaml index 93294856ea..51ae50792f 100644 --- a/kyma/extensions/kyma/kyma.yaml +++ b/kyma/extensions/kyma/kyma.yaml @@ -77,7 +77,20 @@ data: - name: Version source: version widget: Text - - name: State + - name: Module State + widget: Badge + source: "$getModuleState($$)" + description: "$getModuleDescription($$)" + highlights: + positive: + - 'Ready' + critical: + - 'Error' + none: + - 'Processing' + - 'Deleting' + - 'Unknown' + - name: Installation State widget: Badge source: 'state ? state : "UNNKOWN"' description: 'message ? message : ""' diff --git a/public/defaultConfig.yaml b/public/defaultConfig.yaml index 15c3504ba3..a59bdaad55 100644 --- a/public/defaultConfig.yaml +++ b/public/defaultConfig.yaml @@ -49,6 +49,8 @@ config: isEnabled: true EXTENSIBILITY_INJECTIONS: isEnabled: true + EXTENSIBILITY_CUSTOM_COMPONENTS: + isEnabled: false EXTENSIBILITY_WIZARD: isEnabled: true TRACKING: diff --git a/sec-scanners-config.yaml b/sec-scanners-config.yaml index 7534b973de..97f1684e2e 100644 --- a/sec-scanners-config.yaml +++ b/sec-scanners-config.yaml @@ -5,6 +5,8 @@ protecode: whitesource: language: javascript exclude: + - 'package-lock.json' + - '**/backend/package-lock.json' - '**/backend/config/config.yaml' - '**/tests/**' - '**/kyma/enviroments/**' diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx index 35a42b300d..cbabe87e08 100644 --- a/src/components/App/App.tsx +++ b/src/components/App/App.tsx @@ -39,6 +39,7 @@ import { useAfterInitHook } from 'state/useAfterInitHook'; import useSidebarCondensed from 'sidebar/useSidebarCondensed'; import { useGetValidationEnabledSchemas } from 'state/validationEnabledSchemasAtom'; import { useGetKymaResources } from 'state/kymaResourcesAtom'; +import { Spinner } from 'shared/components/Spinner/Spinner'; export default function App() { const language = useRecoilValue(languageAtom); @@ -59,7 +60,7 @@ export default function App() { useResourceSchemas(); useSidebarCondensed(); - useAuthHandler(); + const { isLoading } = useAuthHandler(); useGetConfiguration(); useGetExtensions(); useGetExtensibilitySchemas(); @@ -75,6 +76,10 @@ export default function App() { useAfterInitHook(kubeconfigIdState); useGetKymaResources(); + if (isLoading) { + return ; + } + return (
diff --git a/src/components/Extensibility/ExtensibilityList.js b/src/components/Extensibility/ExtensibilityList.js index 3c1bb5024b..90864aab30 100644 --- a/src/components/Extensibility/ExtensibilityList.js +++ b/src/components/Extensibility/ExtensibilityList.js @@ -1,5 +1,6 @@ import pluralize from 'pluralize'; import { useTranslation } from 'react-i18next'; +import { useEffect } from 'react'; import { ResourcesList } from 'shared/components/ResourcesList/ResourcesList'; import { usePrepareListProps } from 'resources/helpers'; @@ -22,6 +23,9 @@ import { sortBy } from './helpers/sortBy'; import { Widget } from './components/Widget'; import { DataSourcesContextProvider } from './contexts/DataSources'; import { useJsonata } from './hooks/useJsonata'; +import { useFeature } from 'hooks/useFeature'; +import { createPortal } from 'react-dom'; +import YamlUploadDialog from 'resources/Namespaces/YamlUpload/YamlUploadDialog'; export const ExtensibilityListCore = ({ resMetaData, @@ -140,6 +144,43 @@ const ExtensibilityList = ({ overrideResMetadata, ...props }) => { const defaultResMetadata = useGetCRbyPath(); const resMetaData = overrideResMetadata || defaultResMetadata; const { urlPath, defaultPlaceholder } = resMetaData?.general ?? {}; + const { isEnabled: isExtensibilityCustomComponentsEnabled } = useFeature( + 'EXTENSIBILITY_CUSTOM_COMPONENTS', + ); + + useEffect(() => { + const customElement = resMetaData?.general?.customElement; + const customScript = resMetaData?.customScript; + + if ( + isExtensibilityCustomComponentsEnabled && + customElement && + customScript && + !customElements.get(customElement) + ) { + const script = document.createElement('script'); + script.type = 'module'; + const scriptBlob = new Blob([customScript], { + type: 'application/javascript', + }); + const blobURL = URL.createObjectURL(scriptBlob); + script.src = blobURL; + + // Clean up the Blob URL after the script loads + script.onload = () => { + URL.revokeObjectURL(blobURL); + }; + + script.onerror = e => { + console.error('Script loading or execution error:', e); + }; + document.head.appendChild(script); + + return () => { + document.head.removeChild(script); + }; + } + }, [resMetaData, isExtensibilityCustomComponentsEnabled]); return ( { > - + {isExtensibilityCustomComponentsEnabled && resMetaData.customHtml ? ( + <> +
+ {createPortal(, document.body)} + + ) : ( + + )}
diff --git a/src/components/Extensibility/components-form/GenericList.js b/src/components/Extensibility/components-form/GenericList.js index 18341edf0d..d8afcb3342 100644 --- a/src/components/Extensibility/components-form/GenericList.js +++ b/src/components/Extensibility/components-form/GenericList.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import { PluginStack, useUIStore } from '@ui-schema/ui-schema'; import { Button } from '@ui5/webcomponents-react'; import { useTranslation } from 'react-i18next'; diff --git a/src/components/Extensibility/components/Badge.js b/src/components/Extensibility/components/Badge.js index ad07f66233..a9b839f6fd 100644 --- a/src/components/Extensibility/components/Badge.js +++ b/src/components/Extensibility/components/Badge.js @@ -67,6 +67,7 @@ export function Badge({ else if (type === 'informative') type = 'Information'; else if (type === 'positive') type = 'Positive'; else if (type === 'critical') type = 'Negative'; + else if (type === 'none') type = 'Neutral'; type = TYPE_FALLBACK.get(type) || type; diff --git a/src/components/Extensibility/components/FeaturedCard/FeaturedCard.scss b/src/components/Extensibility/components/FeaturedCard/FeaturedCard.scss index fca4f1243b..2e1e9020c2 100644 --- a/src/components/Extensibility/components/FeaturedCard/FeaturedCard.scss +++ b/src/components/Extensibility/components/FeaturedCard/FeaturedCard.scss @@ -3,6 +3,11 @@ margin-left: 0.25rem !important; margin-right: 0.25rem !important; } + +.banner-carousel { + height: fit-content; +} + .feature-card { position: relative; display: flex; diff --git a/src/components/Extensibility/helpers/jsonataWrapper.ts b/src/components/Extensibility/helpers/jsonataWrapper.ts index fdf12715cc..b9cefac11e 100644 --- a/src/components/Extensibility/helpers/jsonataWrapper.ts +++ b/src/components/Extensibility/helpers/jsonataWrapper.ts @@ -6,6 +6,7 @@ import { doesUserHavePermission } from 'state/navigation/filters/permissions'; import { permissionSetsSelector } from 'state/permissionSetsSelector'; import { jwtDecode } from 'jwt-decode'; import { AuthDataState, authDataState } from 'state/authDataAtom'; +import { useModuleStatus } from '../../KymaModules/support'; /* Turns jsonata expressions like @@ -64,6 +65,16 @@ export function jsonataWrapper(expression: string) { }, ); + exp.registerFunction('getModuleState', resource => { + const { data: status } = useModuleStatus(resource); + return status?.state || 'Unknown'; + }); + + exp.registerFunction('getModuleDescription', resource => { + const { data: status } = useModuleStatus(resource); + return status?.description; + }); + exp.registerFunction('compareStrings', (first, second) => { return first?.localeCompare(second) ?? 1; }); diff --git a/src/components/HelmReleases/HelmReleasesList.js b/src/components/HelmReleases/HelmReleasesList.js index ababeb4705..8ace16c496 100644 --- a/src/components/HelmReleases/HelmReleasesList.js +++ b/src/components/HelmReleases/HelmReleasesList.js @@ -72,36 +72,34 @@ function HelmReleasesList() { ]; return ( - <> - a.releaseName.localeCompare(b.releaseName), - }} - searchSettings={{ - textSearchProperties: [ - 'recentRelease.chart.metadata.name', - 'releaseName', - ], - }} - emptyListProps={{ - subtitleText: ResourceDescription, - url: docsURL, - showButton: false, - }} - readOnly - description={ResourceDescription} - /> - + a.releaseName.localeCompare(b.releaseName), + }} + searchSettings={{ + textSearchProperties: [ + 'recentRelease.chart.metadata.name', + 'releaseName', + ], + }} + emptyListProps={{ + subtitleText: ResourceDescription, + url: docsURL, + showButton: false, + }} + readOnly + description={ResourceDescription} + /> ); } diff --git a/src/components/KymaModules/KymaModulesAddModule.js b/src/components/KymaModules/KymaModulesAddModule.js index e0ca44c083..6c4f9df6c4 100644 --- a/src/components/KymaModules/KymaModulesAddModule.js +++ b/src/components/KymaModules/KymaModulesAddModule.js @@ -25,11 +25,18 @@ export default function KymaModulesAddModule({ const modulesResourceUrl = `/apis/operator.kyma-project.io/v1beta2/moduletemplates`; + const modulesReleaseMetaResourceUrl = `/apis/operator.kyma-project.io/v1beta2/modulereleasemetas`; + const { data: modules } = useGet(modulesResourceUrl, { pollingInterval: 3000, skip: !resourceName, }); + const { data: moduleReleaseMetas } = useGet(modulesReleaseMetaResourceUrl, { + pollingInterval: 3000, + skip: !resourceName, + }); + const [columnsCount, setColumnsCount] = useState(2); const [cardsContainerRef, setCardsContainerRef] = useState(null); @@ -77,29 +84,65 @@ export default function KymaModulesAddModule({ const isAlreadyInstalled = initialUnchangedResource?.spec?.modules?.find( installedModule => installedModule.name === name, ); + const moduleMetaRelase = moduleReleaseMetas?.items.find( + item => item.spec.moduleName === name, + ); - if (!existingModule && !isAlreadyInstalled) { - acc.push({ - name: name, - channels: [ - { - channel: module.spec.channel, - version: module.spec.descriptor.component.version, - isBeta: - module.metadata.labels['operator.kyma-project.io/beta'] === - 'true', - }, - ], - docsUrl: - module.metadata.annotations['operator.kyma-project.io/doc-url'], - }); - } else if (existingModule) { - existingModule.channels?.push({ - channel: module.spec.channel, - version: module.spec.descriptor.component.version, - isBeta: - module.metadata.labels['operator.kyma-project.io/beta'] === 'true', - }); + if (module.spec.channel) { + if (!existingModule && !isAlreadyInstalled) { + acc.push({ + name: name, + channels: [ + { + channel: module.spec.channel, + version: module.spec.descriptor.component.version, + isBeta: + module.metadata.labels['operator.kyma-project.io/beta'] === + 'true', + }, + ], + docsUrl: + module.metadata.annotations['operator.kyma-project.io/doc-url'], + }); + } else if (existingModule) { + existingModule.channels?.push({ + channel: module.spec.channel, + version: module.spec.descriptor.component.version, + isBeta: + module.metadata.labels['operator.kyma-project.io/beta'] === 'true', + }); + } + } else { + if (!existingModule && !isAlreadyInstalled) { + moduleMetaRelase?.spec.channels.forEach(channel => { + if (!acc.find(item => item.name === name)) { + acc.push({ + name: name, + channels: [ + { + channel: channel.channel, + version: channel.version, + isBeta: + module.metadata.labels['operator.kyma-project.io/beta'] === + 'true', + }, + ], + docsUrl: + module.metadata.annotations['operator.kyma-project.io/doc-url'], + }); + } else { + acc + .find(item => item.name === name) + .channels.push({ + channel: channel.channel, + version: channel.version, + isBeta: + module.metadata.labels['operator.kyma-project.io/beta'] === + 'true', + }); + } + }); + } } return acc ?? []; diff --git a/src/components/KymaModules/KymaModulesCreate.js b/src/components/KymaModules/KymaModulesCreate.js index 8f98f8c70a..f926979c88 100644 --- a/src/components/KymaModules/KymaModulesCreate.js +++ b/src/components/KymaModules/KymaModulesCreate.js @@ -44,7 +44,17 @@ export default function KymaModulesCreate({ resource, ...props }) { const resourceName = kymaResource?.metadata.name; const modulesResourceUrl = `/apis/operator.kyma-project.io/v1beta2/moduletemplates`; - const { data: modules, loading } = useGet(modulesResourceUrl, { + const modulesReleaseMetaResourceUrl = `/apis/operator.kyma-project.io/v1beta2/modulereleasemetas`; + + const { data: modules, loading: lodingModules } = useGet(modulesResourceUrl, { + pollingInterval: 3000, + skip: !resourceName, + }); + + const { + data: moduleReleaseMetas, + loading: loadingModulesReleaseMetas, + } = useGet(modulesReleaseMetaResourceUrl, { pollingInterval: 3000, skip: !resourceName, }); @@ -68,7 +78,7 @@ export default function KymaModulesCreate({ resource, ...props }) { onSave: false, }); - if (loading) { + if (lodingModules || loadingModulesReleaseMetas) { return (
@@ -131,29 +141,65 @@ export default function KymaModulesCreate({ resource, ...props }) { const name = module.metadata?.labels['operator.kyma-project.io/module-name']; const existingModule = acc.find(item => item.name === name); + const moduleMetaRelase = moduleReleaseMetas?.items.find( + item => item.spec.moduleName === name, + ); - if (!existingModule) { - acc.push({ - name: name, - channels: [ - { - channel: module.spec.channel, - version: module.spec.descriptor.component.version, - isBeta: - module.metadata.labels['operator.kyma-project.io/beta'] === - 'true', - }, - ], - docsUrl: - module.metadata.annotations['operator.kyma-project.io/doc-url'], - }); + if (module.spec.channel) { + if (!existingModule) { + acc.push({ + name: name, + channels: [ + { + channel: module.spec.channel, + version: module.spec.descriptor.component.version, + isBeta: + module.metadata.labels['operator.kyma-project.io/beta'] === + 'true', + }, + ], + docsUrl: + module.metadata.annotations['operator.kyma-project.io/doc-url'], + }); + } else if (existingModule) { + existingModule.channels?.push({ + channel: module.spec.channel, + version: module.spec.descriptor.component.version, + isBeta: + module.metadata.labels['operator.kyma-project.io/beta'] === 'true', + }); + } } else { - existingModule.channels?.push({ - channel: module.spec.channel, - version: module.spec.descriptor.component.version, - isBeta: - module.metadata.labels['operator.kyma-project.io/beta'] === 'true', - }); + if (!existingModule) { + moduleMetaRelase?.spec.channels.forEach(channel => { + if (!acc.find(item => item.name === name)) { + acc.push({ + name: name, + channels: [ + { + channel: channel.channel, + version: channel.version, + isBeta: + module.metadata.labels['operator.kyma-project.io/beta'] === + 'true', + }, + ], + docsUrl: + module.metadata.annotations['operator.kyma-project.io/doc-url'], + }); + } else { + acc + .find(item => item.name === name) + .channels.push({ + channel: channel.channel, + version: channel.version, + isBeta: + module.metadata.labels['operator.kyma-project.io/beta'] === + 'true', + }); + } + }); + } } return acc; }, []); @@ -236,9 +282,11 @@ export default function KymaModulesCreate({ resource, ...props }) { value={channel.channel} additionalText={channel?.isBeta ? 'Beta' : ''} > - {`${channel.channel[0].toUpperCase()}${channel.channel.slice( - 1, - )} (v${channel.version})`}{' '} + {`${( + channel?.channel[0] || '' + ).toUpperCase()}${channel.channel.slice(1)} (v${ + channel.version + })`}{' '} ))} diff --git a/src/components/KymaModules/KymaModulesList.js b/src/components/KymaModules/KymaModulesList.js index 52ed92a242..5952056e45 100644 --- a/src/components/KymaModules/KymaModulesList.js +++ b/src/components/KymaModules/KymaModulesList.js @@ -201,7 +201,7 @@ export default function KymaModulesList({ <> {moduleStatus?.channel ? moduleStatus?.channel - : EMPTY_TEXT_PLACEHOLDER} + : kymaResource?.spec?.modules?.[moduleIndex]?.channel} {isChannelOverriden ? ( + diff --git a/src/components/KymaModules/ModulesCard.js b/src/components/KymaModules/ModulesCard.js index 747ba14add..84ef68a469 100644 --- a/src/components/KymaModules/ModulesCard.js +++ b/src/components/KymaModules/ModulesCard.js @@ -109,9 +109,11 @@ export default function ModulesCard({ value={channel.channel} additionalText={channel?.isBeta ? 'Beta' : ''} > - {`${channel.channel[0].toUpperCase()}${channel.channel.slice( - 1, - )} (v${channel.version})`}{' '} + {`${( + channel?.channel[0] || '' + ).toUpperCase()}${channel.channel.slice(1)} (v${ + channel.version + })`}{' '} ))} diff --git a/src/components/Nodes/NodeResources/NodeResources.js b/src/components/Nodes/NodeResources/NodeResources.js index 9a1e44cfc0..28996dbf83 100644 --- a/src/components/Nodes/NodeResources/NodeResources.js +++ b/src/components/Nodes/NodeResources/NodeResources.js @@ -39,7 +39,7 @@ export function NodeResources({ metrics, resources }) { max={memory.capacity} additionalInfo={`${roundTwoDecimals( memory.usage, - )}GiB / ${roundTwoDecimals(memory.capacity)}GiB`} + )}Gi / ${roundTwoDecimals(memory.capacity)}Gi`} /> diff --git a/src/components/Nodes/nodeQueries.js b/src/components/Nodes/nodeQueries.js index 335e35bf15..55585c57d1 100644 --- a/src/components/Nodes/nodeQueries.js +++ b/src/components/Nodes/nodeQueries.js @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useGet } from 'shared/hooks/BackendAPI/useGet'; import { getBytes, @@ -22,27 +22,30 @@ const formatMemory = memoryStr => const createUsageMetrics = (node, metricsForNode) => { const cpuUsage = formatCpu(metricsForNode?.usage.cpu); const memoryUsage = formatMemory(metricsForNode?.usage.memory); - const cpuCapacity = parseInt(node.status.capacity?.cpu || '0') * 1000; - const memoryCapacity = formatMemory(node.status.capacity?.memory); + const cpuCapacity = parseInt(node.status.allocatable?.cpu || '0'); + const memoryCapacity = formatMemory(node.status.allocatable?.memory); + + const cpuPercentage = getPercentageFromUsage(cpuUsage, cpuCapacity); + const memoryPercentage = getPercentageFromUsage(memoryUsage, memoryCapacity); return { cpu: { usage: cpuUsage, capacity: cpuCapacity, - percentage: getPercentageFromUsage(cpuUsage, cpuCapacity) + '%', - percentageValue: getPercentageFromUsage(cpuUsage, cpuCapacity), + percentage: cpuPercentage + '%', + percentageValue: cpuPercentage, }, memory: { usage: memoryUsage, capacity: memoryCapacity, - percentage: getPercentageFromUsage(memoryUsage, memoryCapacity) + '%', - percentageValue: getPercentageFromUsage(memoryUsage, memoryCapacity), + percentage: memoryPercentage + '%', + percentageValue: memoryPercentage, }, }; }; export function useNodesQuery(skip = false) { - const [data, setData] = React.useState(null); + const [data, setData] = useState(null); const { data: nodeMetrics, loading: metricsLoading } = useGet( '/apis/metrics.k8s.io/v1beta1/nodes', { @@ -57,7 +60,7 @@ export function useNodesQuery(skip = false) { loading: nodesLoading, } = useGet('/api/v1/nodes', { pollingInterval: 5500, skip }); - React.useEffect(() => { + useEffect(() => { if (nodes) { const getNodeMetrics = node => { const metricsForNode = nodeMetrics.items.find( @@ -83,7 +86,7 @@ export function useNodesQuery(skip = false) { } export function useNodeQuery(nodeName) { - const [data, setData] = React.useState(null); + const [data, setData] = useState(null); const { data: nodeMetrics, error: metricsError, @@ -98,7 +101,7 @@ export function useNodeQuery(nodeName) { loading: nodeLoading, } = useGet(`/api/v1/nodes/${nodeName}`, { pollingInterval: 3000 }); - React.useEffect(() => { + useEffect(() => { if (node) { setData({ node, @@ -152,7 +155,9 @@ function addResources(a, b) { function sumContainersResources(containers) { return containers?.reduce((containerAccu, container) => { - return addResources(containerAccu, container.resources); + const containerResources = container.resources; + const updatedResources = addResources(containerAccu, containerResources); + return updatedResources; }, structuredClone(emptyResources)); } @@ -181,14 +186,14 @@ export function calcNodeResources(pods) { } export function useResourceByNode(nodeName) { - const [data, setData] = React.useState(null); + const [data, setData] = useState(null); const { data: pods, error, loading } = useGet( `/api/v1/pods?fieldSelector=spec.nodeName=${nodeName},status.phase!=Failed,status.phase!=Succeeded&limit=500`, ); const nodeResources = useMemo(() => calcNodeResources(pods), [pods]); - React.useEffect(() => { + useEffect(() => { if (nodeResources) { setData(nodeResources); } diff --git a/src/header/Header.tsx b/src/header/Header.tsx index 781150edf0..52ac4b7e08 100644 --- a/src/header/Header.tsx +++ b/src/header/Header.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useState } from 'react'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { Avatar, @@ -8,7 +8,6 @@ import { MenuDomRef, ShellBar, ShellBarItem, - ShellBarDomRef, ListItemStandard, } from '@ui5/webcomponents-react'; import { MenuItemClickEventDetail } from '@ui5/webcomponents/dist/Menu.js'; @@ -55,21 +54,11 @@ export function Header() { ); const [isFormOpen, setIsFormOpen] = useRecoilState(isFormOpenState); - const shellbarRef = useRef(null); - useEffect(() => { - if (shellbarRef?.current) { - shellbarRef.current.accessibilityTexts = { - ...shellbarRef.current.accessibilityTexts, - logoTitle: 'SAP Kyma logo', - }; - } - }, [shellbarRef]); - const inactiveClusterNames = Object.keys(clusters || {}).filter( name => name !== cluster?.name, ); - const nonBreakableSpaces = (number: int): string => { + const nonBreakableSpaces = (number: number): string => { let spaces = ''; for (let i = 0; i < number; i++) { spaces += '\u00a0'; @@ -120,10 +109,14 @@ export function Header() { <> } - ref={shellbarRef} onLogoClick={() => { handleActionIfFormOpen( isResourceEdited, @@ -225,7 +218,6 @@ export function Header() { text={t('common.labels.version')} additionalText={busolaVersion} icon="inspect" - startsSection /> diff --git a/src/resources/Deployments/DeploymentDetails.js b/src/resources/Deployments/DeploymentDetails.js index 5740639484..c6448828a9 100644 --- a/src/resources/Deployments/DeploymentDetails.js +++ b/src/resources/Deployments/DeploymentDetails.js @@ -64,7 +64,12 @@ export function DeploymentDetails(props) { const statusConditions = deployment => { return deployment?.status?.conditions?.map(condition => { return { - header: { titleText: condition.type, status: condition.status }, + header: { + titleText: condition.type, + status: condition.status, + overrideStatusType: + condition.type === 'ReplicaFailure' ? 'False' : condition.status, + }, message: condition.message, }; }); diff --git a/src/resources/Deployments/DeploymentStatus.js b/src/resources/Deployments/DeploymentStatus.js index 5bf2456caf..0fb4fe10d3 100644 --- a/src/resources/Deployments/DeploymentStatus.js +++ b/src/resources/Deployments/DeploymentStatus.js @@ -4,7 +4,7 @@ import { RunningPodsStatus } from 'shared/components/RunningPodsStatus'; export function DeploymentStatus({ deployment }) { const running = deployment.status.readyReplicas || 0; - const expected = deployment.status.replicas || 0; + const expected = deployment.status.replicas || deployment.spec.replicas || 0; return ; } diff --git a/src/resources/Namespaces/AllNamespacesDetails.js b/src/resources/Namespaces/AllNamespacesDetails.js index 0fb112b522..8302f233d6 100644 --- a/src/resources/Namespaces/AllNamespacesDetails.js +++ b/src/resources/Namespaces/AllNamespacesDetails.js @@ -44,7 +44,7 @@ export function AllNamespacesDetails() { const Events = ; const headerActions = ( - <> +
{createPortal(, document.body)} - +
); return ( diff --git a/src/resources/Namespaces/NamespaceWorkloads/NamespaceWorkloadsHelpers.js b/src/resources/Namespaces/NamespaceWorkloads/NamespaceWorkloadsHelpers.js index 2e72432885..d935525d1f 100644 --- a/src/resources/Namespaces/NamespaceWorkloads/NamespaceWorkloadsHelpers.js +++ b/src/resources/Namespaces/NamespaceWorkloads/NamespaceWorkloadsHelpers.js @@ -1,8 +1,12 @@ import { calculatePodState } from 'resources/Pods/PodStatus'; export function getHealthyReplicasCount(resource) { - return resource?.filter(r => r.status.replicas === r.status.readyReplicas) - ?.length; + return resource?.filter(r => { + const running = r.status.readyReplicas || 0; + const expected = r.status.replicas || r.spec.replicas || 0; + + return running === expected; + })?.length; } export const PodStatusCounterKey = { diff --git a/src/resources/Namespaces/ResourcesUsage.js b/src/resources/Namespaces/ResourcesUsage.js index 718403e83f..3bc6eb5426 100644 --- a/src/resources/Namespaces/ResourcesUsage.js +++ b/src/resources/Namespaces/ResourcesUsage.js @@ -9,6 +9,7 @@ import { Card, CardHeader } from '@ui5/webcomponents-react'; const MEMORY_SUFFIX_POWER = { // must be sorted from the smallest to the largest; it is case sensitive; more info: https://medium.com/swlh/understanding-kubernetes-resource-cpu-and-memory-units-30284b3cc866 m: 1e-3, + k: 1e3, K: 1e3, Ki: 2 ** 10, M: 1e6, @@ -22,24 +23,13 @@ const CPU_SUFFIX_POWER = { m: 1e-3, }; -export function getBytes(memoryString) { - if (!memoryString || memoryString === '0') { - return 0; - } - const suffixMatch = String(memoryString).match(/\D+$/); - - if (!suffixMatch?.length) { - return memoryString; - } - const suffix = suffixMatch[0]; - const number = String(memoryString).replace(suffix, ''); +export function getBytes(memoryStr) { + if (!memoryStr) return 0; - const suffixPower = MEMORY_SUFFIX_POWER[suffix]; - if (!suffixPower) { - return number; - } - - return number * suffixPower; + const unit = String(memoryStr).match(/[a-zA-Z]+/g)?.[0]; + const value = parseFloat(memoryStr); + const bytes = value * (MEMORY_SUFFIX_POWER[unit] || 1); + return bytes; } export function getCpus(cpuString) { diff --git a/src/resources/ServiceAccounts/TokenRequestModal/TokenRequestModal.tsx b/src/resources/ServiceAccounts/TokenRequestModal/TokenRequestModal.tsx index bb21cef04a..f32aebef2d 100644 --- a/src/resources/ServiceAccounts/TokenRequestModal/TokenRequestModal.tsx +++ b/src/resources/ServiceAccounts/TokenRequestModal/TokenRequestModal.tsx @@ -8,6 +8,7 @@ import { ResourceForm } from 'shared/ResourceForm'; import { ComboboxInput } from 'shared/ResourceForm/inputs'; import { CopiableText } from 'shared/components/CopiableText/CopiableText'; import { Editor } from 'shared/components/MonacoEditorESM/Editor'; +import { useRef } from 'react'; const expirationSecondsOptions = [ { @@ -71,6 +72,7 @@ export function TokenRequestModal({ }: TokenRequestModalProps) { const { t } = useTranslation(); const downloadKubeconfig = useDownloadKubeconfigWithToken(); + const modalRef = useRef(null); const { kubeconfigYaml, @@ -78,7 +80,12 @@ export function TokenRequestModal({ generateTokenRequest, tokenRequest, setTokenRequest, - } = useGenerateTokenRequest(isModalOpen, namespace, serviceAccountName); + } = useGenerateTokenRequest( + isModalOpen, + namespace, + serviceAccountName, + modalRef, + ); const isExpirationSecondsValueANumber = () => !Number(tokenRequest.spec.expirationSeconds); @@ -100,6 +107,7 @@ export function TokenRequestModal({ open={isModalOpen} onClose={handleCloseModal} headerText={t('service-accounts.token-request.generate')} + ref={modalRef} footer={ , ) => { const { t } = useTranslation(); const post = usePost(); @@ -44,6 +45,7 @@ export const useGenerateTokenRequest = ( notifyToast( { content: t('service-accounts.token-request.notification.success'), + parentContainer: generate ? modalRef?.current : undefined, }, 3000, ); diff --git a/src/resources/createResourceRoutes.js b/src/resources/createResourceRoutes.js index 845145da95..9c1052751c 100644 --- a/src/resources/createResourceRoutes.js +++ b/src/resources/createResourceRoutes.js @@ -84,17 +84,31 @@ const ColumnWrapper = ({ }); const elementListProps = usePrepareListProps({ - ...props, + resourceCustomType: props.resourceCustomType, + resourceType: props.resourceType, + resourceI18Key: props.resourceI18Key, + apiGroup: props.apiGroup, + apiVersion: props.apiVersion, + hasDetailsView: props.hasDetailsView, }); const elementDetailsProps = usePrepareDetailsProps({ - ...props, + resourceCustomType: props.resourceCustomType, + resourceType: props.resourceType, + resourceI18Key: props.resourceI18Key, + apiGroup: props.apiGroup, + apiVersion: props.apiVersion, resourceName: layoutState?.midColumn?.resourceName ?? resourceName, namespaceId: layoutState?.midColumn?.namespaceId ?? namespaceId, + showYamlTab: props.showYamlTab, }); const elementCreateProps = usePrepareCreateProps({ - ...props, + resourceCustomType: props.resourceCustomType, + resourceType: props.resourceType, + resourceTypeForTitle: props.resourceType, + apiGroup: props.apiGroup, + apiVersion: props.apiVersion, }); const listComponent = React.cloneElement(list, { @@ -122,16 +136,13 @@ const ColumnWrapper = ({ detailsMidColumn = detailsComponent; } - const { schema, loading } = useGetSchema({ + const { schema } = useGetSchema({ resource: { group: props?.apiGroup, version: props.apiVersion, kind: props?.resourceType.slice(0, -1), }, }); - if (loading) { - return null; - } const createMidColumn = ( + { let startColumnComponent = null; const headerActions = ( - <> +
@@ -139,7 +139,7 @@ const ColumnWraper = (defaultColumn = 'list') => { />, document.body, )} - +
); if (!layout && defaultColumn === 'details') { diff --git a/src/shared/components/ConditionList/ConditionList.tsx b/src/shared/components/ConditionList/ConditionList.tsx index 397c7390b5..dae0b0dab3 100644 --- a/src/shared/components/ConditionList/ConditionList.tsx +++ b/src/shared/components/ConditionList/ConditionList.tsx @@ -18,6 +18,7 @@ type ConditionItem = { type ConditionHeader = { titleText: string | ReactNode; status?: string; + overrideStatusType?: string; }; export const ConditionList = ({ @@ -34,6 +35,7 @@ export const ConditionList = ({ key={index} header={cond.header?.titleText} status={cond.header?.status} + overrideStatusType={cond.header?.overrideStatusType} content={cond.message} customContent={cond.customContent} /> diff --git a/src/shared/components/DynamicPageComponent/DynamicPageComponent.js b/src/shared/components/DynamicPageComponent/DynamicPageComponent.js index 3328464404..ed25639c62 100644 --- a/src/shared/components/DynamicPageComponent/DynamicPageComponent.js +++ b/src/shared/components/DynamicPageComponent/DynamicPageComponent.js @@ -11,7 +11,6 @@ import { } from '@ui5/webcomponents-react'; import { Toolbar } from '@ui5/webcomponents-react-compat/dist/components/Toolbar/index.js'; import { ToolbarSpacer } from '@ui5/webcomponents-react-compat/dist/components/ToolbarSpacer/index.js'; -import { ToolbarSeparator } from '@ui5/webcomponents-react-compat/dist/components/ToolbarSeparator/index.js'; import './DynamicPageComponent.scss'; import { useEffect, useState, useRef } from 'react'; @@ -164,15 +163,15 @@ export const DynamicPageComponent = ({ > {actions && ( -
+ <> {actions} {(window.location.search.includes('layout') || (!window.location.search.includes('layout') && layoutColumn?.showCreate?.resourceType)) && layoutNumber !== 'StartColumn' ? ( - + ) : null} -
+ )} {window.location.search.includes('layout') || (!window.location.search.includes('layout') && diff --git a/src/shared/components/DynamicPageComponent/DynamicPageComponent.scss b/src/shared/components/DynamicPageComponent/DynamicPageComponent.scss index 1d512b03ed..a1122a9d0b 100644 --- a/src/shared/components/DynamicPageComponent/DynamicPageComponent.scss +++ b/src/shared/components/DynamicPageComponent/DynamicPageComponent.scss @@ -4,6 +4,7 @@ ui5-dynamic-page-title { justify-content: center; min-height: 3rem; + padding-left: 2rem; } .bold-title { @@ -27,10 +28,6 @@ display: none; } - [data-component-name='ObjectPageTitleMiddleSection'] > div { - flex-basis: 100%; - } - &__actions { display: flex; align-items: center; @@ -57,6 +54,17 @@ position: static; padding: unset; } + + .tab-container { + position: sticky; + z-index: 1; + } +} + +ui5-dynamic-page { + .no-shadow { + box-shadow: none; + } } .header-wrapper { @@ -99,10 +107,6 @@ } } -ui5-button[data-component-name='ObjectPageAnchorBarExpandBtn'] { - display: none; -} - @media (max-width: 768px) { .column-wrapper { display: flex; @@ -110,8 +114,3 @@ ui5-button[data-component-name='ObjectPageAnchorBarExpandBtn'] { gap: 12px; } } - -[data-component-name='DynamicPageContent'], -[data-component-name='ObjectPageContent'] { - padding: 0 !important; -} diff --git a/src/shared/components/ExpandableListItem/ExpandableListItem.tsx b/src/shared/components/ExpandableListItem/ExpandableListItem.tsx index 0f8ad6751a..0af3a9bad2 100644 --- a/src/shared/components/ExpandableListItem/ExpandableListItem.tsx +++ b/src/shared/components/ExpandableListItem/ExpandableListItem.tsx @@ -8,6 +8,7 @@ import './ExpandableListItem.scss'; type ExpandableListItemProps = { header: string | ReactNode; status?: string; + overrideStatusType?: string; content?: string; customContent?: CustomContent[]; }; @@ -21,12 +22,18 @@ export type CustomContent = { export const ExpandableListItem = ({ header, status, + overrideStatusType, content, customContent, }: ExpandableListItemProps) => { const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); + let statusType = status === 'True' ? 'Success' : 'Error'; + if (overrideStatusType !== undefined) { + statusType = overrideStatusType === 'True' ? 'Success' : 'Error'; + } + return ( <> + {status} )} diff --git a/src/shared/components/GenericList/GenericList.js b/src/shared/components/GenericList/GenericList.js index 2c310262fb..e8cf5dab4a 100644 --- a/src/shared/components/GenericList/GenericList.js +++ b/src/shared/components/GenericList/GenericList.js @@ -32,6 +32,7 @@ import pluralize from 'pluralize'; import { isResourceEditedState } from 'state/resourceEditedAtom'; import { isFormOpenState } from 'state/formOpenAtom'; import { handleActionIfFormOpen } from '../UnsavedMessageBox/helpers'; +import './GenericList.scss'; const defaultSort = { name: nameLocaleSort, @@ -113,9 +114,7 @@ export const GenericList = ({ }, [pageSize, pagination]); const { i18n, t } = useTranslation(); - const [currentPage, setCurrentPage] = React.useState( - pagination?.initialPage || 1, - ); + const [currentPage, setCurrentPage] = useState(pagination?.initialPage || 1); const [filteredEntries, setFilteredEntries] = useState(() => sorting(sort, entries), @@ -197,7 +196,7 @@ export const GenericList = ({ const renderTableBody = () => { if (serverDataError) { return ( - +

{getErrorMessage(serverDataError)}

); @@ -205,7 +204,7 @@ export const GenericList = ({ if (serverDataLoading) { return ( - + ); diff --git a/src/shared/components/GenericList/components.js b/src/shared/components/GenericList/components.js index 200d53dbc3..4bad7d571d 100644 --- a/src/shared/components/GenericList/components.js +++ b/src/shared/components/GenericList/components.js @@ -137,7 +137,7 @@ const DefaultRowRenderer = ({ ); return ( - + {cells} {!!actions.length && actionsCell} {displayArrow && ( diff --git a/src/shared/components/ListActions/ListActions.js b/src/shared/components/ListActions/ListActions.js index ba46752312..f635ceef42 100644 --- a/src/shared/components/ListActions/ListActions.js +++ b/src/shared/components/ListActions/ListActions.js @@ -22,7 +22,10 @@ const StandaloneAction = ({ action, entry }) => { return ( - {createPortal( - , - document.body, - )} - - )} - {createPortal(, document.body)} +
+ {!disableDelete && ( + <> + + + {createPortal( + , + document.body, + )} + + )} + {createPortal(, document.body)} +
); diff --git a/src/shared/components/UI5Panel/UI5Panel.tsx b/src/shared/components/UI5Panel/UI5Panel.tsx index 1aaa63aac5..f2c7dd47f6 100644 --- a/src/shared/components/UI5Panel/UI5Panel.tsx +++ b/src/shared/components/UI5Panel/UI5Panel.tsx @@ -38,7 +38,7 @@ export const UI5Panel = ({ if (headerTop !== '0') setTimeout(() => { const stickyHeader = document - .querySelector('ui5-panel') + .querySelector('.resource-form--panel') ?.shadowRoot?.querySelector('.ui5-panel-root') ?.querySelector( '.ui5-panel-heading-wrapper.ui5-panel-heading-wrapper-sticky', diff --git a/src/shared/contexts/ErrorModal/ErrorModal.tsx b/src/shared/contexts/ErrorModal/ErrorModal.tsx index cc244099e0..943f3393c5 100644 --- a/src/shared/contexts/ErrorModal/ErrorModal.tsx +++ b/src/shared/contexts/ErrorModal/ErrorModal.tsx @@ -7,6 +7,7 @@ import './ErrorModal.scss'; export type ToastProps = { content: React.ReactNode; + parentContainer?: HTMLElement | null; }; type CloseFn = () => void; diff --git a/src/shared/contexts/NotificationContext.tsx b/src/shared/contexts/NotificationContext.tsx index 816354a8f7..4bbb956cbb 100644 --- a/src/shared/contexts/NotificationContext.tsx +++ b/src/shared/contexts/NotificationContext.tsx @@ -39,7 +39,7 @@ export const NotificationProvider = ({ const methods = { notifySuccess: function(notificationProps: ToastProps) { setToastProps(notificationProps); - if (toast.current) { + if (toast.current && !toastProps?.parentContainer) { toast.current.open = true; } }, @@ -58,9 +58,31 @@ export const NotificationProvider = ({ ...methods, }} > - - {toastProps?.content} - + {toastProps?.parentContainer && + createPortal( + { + setToastProps(null); + e.stopPropagation(); + }} + > + {toastProps?.content} + , + toastProps.parentContainer, + )} + {!toastProps?.parentContainer && ( + + {toastProps?.content} + + )} {errorProps && createPortal(, document.body)} {children} diff --git a/src/state/authDataAtom.ts b/src/state/authDataAtom.ts index c598899f2b..a5dbdfe90d 100644 --- a/src/state/authDataAtom.ts +++ b/src/state/authDataAtom.ts @@ -1,6 +1,6 @@ import { parseOIDCparams } from 'components/Clusters/components/oidc-params'; import { UserManager, User } from 'oidc-client-ts'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { atom, useSetRecoilState, useRecoilValue, RecoilState } from 'recoil'; import { KubeconfigNonOIDCAuth, KubeconfigOIDCAuth } from 'types'; @@ -137,6 +137,7 @@ export function useAuthHandler() { const setAuth = useSetRecoilState(authDataState); const navigate = useNavigate(); const setLastFetched = useSetRecoilState(openapiLastFetchedState); + const [isLoading, setIsLoading] = useState(true); useEffect(() => { console.log( @@ -145,14 +146,17 @@ export function useAuthHandler() { if (!cluster) { setAuth(null); + setIsLoading(false); } else { // don't do the auth flow on cluster list (e.g. after refresh, while the OIDC cluster is still connected) if (window.location.pathname === '/clusters') { + setIsLoading(false); return; } const userCredentials = cluster.currentContext?.user?.user; if (hasNonOidcAuth(userCredentials)) { setAuth(userCredentials as KubeconfigNonOIDCAuth); + setIsLoading(false); } else { const onAfterLogin = () => { if (!getPreviousPath() || getPreviousPath() === '/clusters') { @@ -166,8 +170,12 @@ export function useAuthHandler() { navigate('/cluster/' + encodeURIComponent(cluster.name)); } } + setIsLoading(false); + }; + const onError = () => { + navigate('/clusters'); + setIsLoading(false); }; - const onError = () => navigate('/clusters'); handleLogin({ userCredentials: userCredentials as KubeconfigOIDCAuth, @@ -181,6 +189,8 @@ export function useAuthHandler() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [cluster]); + + return { isLoading }; } export const authDataState: RecoilState = atom({ diff --git a/src/state/navigation/extensionsAtom.ts b/src/state/navigation/extensionsAtom.ts index 591e35701d..426102e79e 100644 --- a/src/state/navigation/extensionsAtom.ts +++ b/src/state/navigation/extensionsAtom.ts @@ -45,6 +45,8 @@ type ConfigMapData = { dataSources: string; translations: string; presets: string; + customScript: string; + customHtml: string; }; type ConfigMapResponse = K8sResource & { @@ -61,6 +63,10 @@ type ConfigMapListResponse = } | undefined; +interface ExtensionProps { + kymaFetchFn: (url: string, options?: any) => Promise; +} + const isTheSameNameAndUrl = ( firstCM: Partial, secondCM: Partial, @@ -288,6 +294,7 @@ const getExtensions = async ( kubeconfigNamespace = 'kube-public', currentNamespace: string, permissionSet: PermissionSetState, + extCustomComponentsEnabled: boolean, ) => { if (!fetchFn) { return null; @@ -324,6 +331,12 @@ const getExtensions = async ( ) as ExtResource, }; + if (extCustomComponentsEnabled) { + extResourceWithMetadata.data.customHtml = + currentConfigMap.data.customHtml || ''; + extResourceWithMetadata.data.customScript = + currentConfigMap.data.customScript || ''; + } if (!extResourceWithMetadata.data) return accumulator; const indexOfTheSameExtension = accumulator.findIndex(ext => @@ -406,10 +419,43 @@ export const useGetExtensions = () => { const { isEnabled: isExtensibilityWizardEnabled } = useFeature( 'EXTENSIBILITY_WIZARD', ); + const { isEnabled: isExtensibilityCustomComponentsEnabled } = useFeature( + 'EXTENSIBILITY_CUSTOM_COMPONENTS', + ); const { data: crds } = useGet( `/apis/apiextensions.k8s.io/v1/customresourcedefinitions`, ); + useEffect(() => { + if (isExtensibilityCustomComponentsEnabled) { + // Wrap busola fetch function to be able to use it in the extensions as regular fetch. + // It reduces the learning curve for the extension developers and introduces loose coupling between Busola and the extensions. + function asRegularFetch(busolaFetch: FetchFn, url: string, options: any) { + return busolaFetch({ + relativeUrl: url, + init: options, + abortController: options?.signal + ? { signal: options?.signal, abort: () => {} } + : undefined, + }); + } + + if (fetchFn) { + (window as Window & { + extensionProps?: ExtensionProps; + }).extensionProps = { + kymaFetchFn: (url: string, options: any) => + asRegularFetch(fetchFn, url, options), + }; + } + } + + return () => { + delete (window as Window & { extensionProps?: ExtensionProps }) + .extensionProps; + }; + }, [fetchFn, auth, isExtensibilityCustomComponentsEnabled]); + useEffect(() => { (crds as any)?.items.forEach((crd: CustomResourceDefinition) => { RESOURCE_PATH[crd?.spec.names.kind as keyof typeof RESOURCE_PATH] = @@ -433,6 +479,7 @@ export const useGetExtensions = () => { cluster.currentContext.namespace || 'kube-public', namespace, permissionSet, + isExtensibilityCustomComponentsEnabled ?? false, ); const statics = await getStatics( diff --git a/src/state/resourceList/mapExtResourceToNavNode.ts b/src/state/resourceList/mapExtResourceToNavNode.ts index 0c32476077..dc5abe4f68 100644 --- a/src/state/resourceList/mapExtResourceToNavNode.ts +++ b/src/state/resourceList/mapExtResourceToNavNode.ts @@ -1,7 +1,7 @@ import { getExtensibilityPath } from 'components/Extensibility/helpers/getExtensibilityPath'; import pluralize from 'pluralize'; -import { ExtResource, NavNode } from '../types'; +import { configFeaturesNames, ExtResource, NavNode } from '../types'; export const mapExtResourceToNavNode = (extRes: ExtResource) => { const node: NavNode = {} as NavNode; @@ -14,6 +14,11 @@ export const mapExtResourceToNavNode = (extRes: ExtResource) => { node.namespaced = extRes.general.scope === 'namespace'; node.apiGroup = extRes.general.resource.group || ''; node.apiVersion = extRes.general.resource.version; + if (extRes.general.customElement) { + node.requiredFeatures = [ + configFeaturesNames.EXTENSIBILITY_CUSTOM_COMPONENTS, + ]; + } return node; }; diff --git a/src/state/types.ts b/src/state/types.ts index 453b9ae06d..db9e7a62d3 100644 --- a/src/state/types.ts +++ b/src/state/types.ts @@ -22,6 +22,7 @@ export const configFeaturesNames = { VISUAL_RESOURCES: 'VISUAL_RESOURCES', EXTENSIBILITY: 'EXTENSIBILITY', EXTENSIBILITY_INJECTIONS: 'EXTENSIBILITY_INJECTIONS', + EXTENSIBILITY_CUSTOM_COMPONENTS: 'EXTENSIBILITY_CUSTOM_COMPONENTS', EXTENSIBILITY_WIZARD: 'EXTENSIBILITY_WIZARD', TRACKING: 'TRACKING', PROTECTED_RESOURCES: 'PROTECTED_RESOURCES', @@ -84,6 +85,7 @@ export type ExtGeneral = { icon?: string; id?: string; externalNodes?: ExtensibilityNodesExt[]; + customElement?: string; }; export type ExtResource = { @@ -98,6 +100,8 @@ export type ExtResource = { presets: any[]; dataSources: Record; injections?: ExtInjection[]; + customHtml: {}; + customScript: {}; }; export type ExtensibilityNodesExt = { diff --git a/src/styles/index.scss b/src/styles/index.scss index 7ca965bf0f..e8bb926686 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -66,10 +66,6 @@ ui5-button::part(button) { -webkit-font-smoothing: antialiased; } -li { - list-style: none; -} - ui5-checkbox { -webkit-appearance: checkbox; } diff --git a/src/styles/reset.css b/src/styles/reset.css index aba39c7af9..c317155d34 100644 --- a/src/styles/reset.css +++ b/src/styles/reset.css @@ -3,7 +3,6 @@ License: none (public domain) */ -html, ul, p { margin: 0; @@ -15,37 +14,14 @@ p { } /* HTML5 display-role reset for older browsers */ -article, -aside, -details, -figcaption, -figure, -footer, -header, -hgroup, -menu, -nav, -section { - display: block; -} body { line-height: 1; } -ol, + ul { list-style: none; } -blockquote, -q { - quotes: none; -} -blockquote:before, -blockquote:after, -q:before, -q:after { - content: ''; - content: none; -} + table { border-collapse: collapse; border-spacing: 0; diff --git a/tests/integration/tests/accessibility/test-acc-cron-jobs.spec.js b/tests/integration/tests/accessibility/test-acc-cron-jobs.spec.js index c76440b73b..ec3540e1db 100644 --- a/tests/integration/tests/accessibility/test-acc-cron-jobs.spec.js +++ b/tests/integration/tests/accessibility/test-acc-cron-jobs.spec.js @@ -91,8 +91,9 @@ context('Accessibility test Cron Jobs', () => { .clear() .type('*', { force: true }); - cy.get( - '[aria-label="Schedule: At 12:00 AM, on day 1 of the month, expanded"]', + cy.contains( + 'ui5-label', + 'Schedule: At 12:00 AM, on day 1 of the month', ).click(); cy.contains('Command').click(); diff --git a/tests/integration/tests/cluster/test-custom-resources.spec.js b/tests/integration/tests/cluster/test-custom-resources.spec.js index c47041385d..6770ebc32e 100644 --- a/tests/integration/tests/cluster/test-custom-resources.spec.js +++ b/tests/integration/tests/cluster/test-custom-resources.spec.js @@ -108,6 +108,7 @@ context('Test Custom Resources', () => { cy.get('ui5-input[id="search-input"]:visible') .find('input') .wait(1000) + .clear() .type('cypress'); cy.clickGenericListLink('Tclusters');