From 9fd02672aee7139dd4e546e58cbc7125cada1777 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 8 Feb 2023 23:19:35 -0800 Subject: [PATCH 1/3] Add daily-alpha release for open-lens (#7127) Signed-off-by: Sebastian Malton --- .github/workflows/daily-alpha.yml | 103 +++++++++++++++++++++++ .github/workflows/publish-master-npm.yml | 38 --------- 2 files changed, 103 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/daily-alpha.yml delete mode 100644 .github/workflows/publish-master-npm.yml diff --git a/.github/workflows/daily-alpha.yml b/.github/workflows/daily-alpha.yml new file mode 100644 index 0000000000..803be0407b --- /dev/null +++ b/.github/workflows/daily-alpha.yml @@ -0,0 +1,103 @@ +name: Release daily alpha +on: + schedule: + - cron: 0 0 30 * 1-5 # At 12:30am UTC work day + workflow_dispatch: # for testing +jobs: + tag: + outputs: + tagname: v${{ steps.version.outputs.VERSION }} + version: ${{ steps.version.outputs.VERSION }} + continue: ${{ steps.create-branch.outputs.CONTINUE }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: "16.x" + registry-url: "https://npm.pkg.github.com" + + - name: Install deps + run: | + npm i --location=global semver + npm install + + sudo apt-get install -y ripgrep + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Bump version + id: version + run: npm run bump-version-for-cron + - name: Check if branch already exists + id: check-branch + run: git ls-remote --exit-code --tags origin v${{ steps.version.outputs.VERSION }} + continue-on-error: true + - name: Create branch and tag and push + id: create-branch + run: | + # failure means that the tag doesn't exist so we should create it + if [ ${{ steps.check-branch.outcome }} != 'failure' ]; then + echo "CONTINUE=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git config --global user.email "bot@k8slens.dev" + git config --global user.name "k8slens bot" + + git checkout -b release/v${{ steps.version.outputs.VERSION }} + git add . + git commit -sm "Release ${{ steps.version.outputs.VERSION }}" + git tag v${{ steps.version.outputs.VERSION }} + git push origin v${{ steps.version.outputs.VERSION }} + echo "CONTINUE=true" >> "$GITHUB_OUTPUT" + create_release: + outputs: + upload_url: ${{ steps.create_release.outputs.upload_url }} + version: ${{ needs.tag.outputs.version }} + needs: [tag] + if: ${{ needs.tag.outputs.continue == 'true' }} + runs-on: ubuntu-20.04 + steps: + - name: Create GitHub release + uses: softprops/action-gh-release@v1 + id: create_release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ needs.tag.outputs.tagname }} + name: ${{ needs.tag.outputs.tagname }} + generate_release_notes: true + prerelease: true + release_packages: + needs: [create_release] + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v3 + with: + ref: v${{ needs.create_release.outputs.version }} + - name: Use Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v3 + with: + node-version: "16.x" + cache: "npm" + registry-url: "https://npm.pkg.github.com" + - name: Build package + shell: bash + run: | + yarn install --frozen-lockfile + yarn run build + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Release to GitHub NPM registry + run: | + npm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN}" + yarn lerna \ + publish from-package \ + --no-push \ + --no-git-tag-version \ + --yes \ + --dist-tag cron + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/publish-master-npm.yml b/.github/workflows/publish-master-npm.yml deleted file mode 100644 index 464e2adbe7..0000000000 --- a/.github/workflows/publish-master-npm.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Publish NPM Package `master` -on: - pull_request: - types: - - closed -concurrency: - group: publish-master-npm - cancel-in-progress: true -jobs: - publish: - name: Publish Extensions NPM Package `master` - runs-on: ubuntu-latest - if: ${{ github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'area/extension') }} - strategy: - matrix: - node-version: [16.x] - steps: - - name: Checkout Release - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Using Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - - - name: Generate NPM packages - run: | - yarn install --frozen-lockfile - yarn run build - - - name: Publish NPM package - run: | - npm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN}" - yarn lerna publish from-package --dist-tag master --no-push --no-git-tag-version --yes - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} From 5d21db9fc2336acd5bab0b150067cb48714e59f7 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Thu, 9 Feb 2023 23:05:13 -0800 Subject: [PATCH 2/3] Remove self-referencial from root package.json (#7130) Signed-off-by: Sebastian Malton --- .github/workflows/publish-release-npm.yml | 2 +- package.json | 5 ++--- yarn.lock | 11 ----------- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/.github/workflows/publish-release-npm.yml b/.github/workflows/publish-release-npm.yml index cb87159709..b407ed4ff2 100644 --- a/.github/workflows/publish-release-npm.yml +++ b/.github/workflows/publish-release-npm.yml @@ -40,7 +40,7 @@ jobs: npm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN}" VERSION=$(cat lerna.json | jq '.version' --raw-output) echo ${VERSION} - DIST_TAG=$(npm exec -- @k8slens/semver --prerelease 0 ${VERSION}) + DIST_TAG=$(node packages/semver/dist/index.js --prerelease 0 ${VERSION}) yarn lerna \ publish from-package \ --no-push \ diff --git a/package.json b/package.json index 3ccfff7c4e..2728d68dea 100644 --- a/package.json +++ b/package.json @@ -18,12 +18,11 @@ "test:unit": "lerna run --stream test:unit", "test:integration": "lerna run --stream test:integration", "bump-version": "lerna version --no-git-tag-version --no-push", - "create-release-pr": "create-release-pr", + "precreate-release-pr": "cd packages/release-tool && yarn run build", + "create-release-pr": "node packages/release-tool/dist/index.js", "postinstall": "lerna bootstrap --ignore open-lens && lerna bootstrap --scope open-lens --include-dependencies" }, "devDependencies": { - "@k8slens/semver": "./packages/semver", - "@k8slens/release-tool": "./packages/release-tool", "adr": "^1.4.3", "cross-env": "^7.0.3", "lerna": "^6.4.1", diff --git a/yarn.lock b/yarn.lock index f9b6dc34af..418188487f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -38,17 +38,6 @@ resolved "https://registry.yarnpkg.com/@isaacs/string-locale-compare/-/string-locale-compare-1.1.0.tgz#291c227e93fd407a96ecd59879a35809120e432b" integrity sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ== -"@k8slens/release-tool@./packages/release-tool": - version "6.4.0-beta.13" - dependencies: - rimraf "^4.1.2" - -"@k8slens/semver@./packages/semver": - version "6.4.0-beta.13" - dependencies: - command-line-args "^5.2.1" - semver "^7.3.8" - "@lerna/add@6.4.1": version "6.4.1" resolved "https://registry.yarnpkg.com/@lerna/add/-/add-6.4.1.tgz#fa20fe9ff875dc5758141262c8cde0d9a6481ec4" From 0719293b11945e7ecce2f6ef646ab89ad5c92a6d Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Mon, 13 Feb 2023 09:34:03 +0300 Subject: [PATCH 3/3] Namespace details tree view (#7080) * Initial tests for Signed-off-by: Alex Andreev * Introduce Signed-off-by: Alex Andreev * Render namespace children Signed-off-by: Alex Andreev * Render a child subnamespace Signed-off-by: Alex Andreev * Remove unused lodash import Signed-off-by: Alex Andreev * Render subnamespace badge after name Signed-off-by: Alex Andreev * Testing rendering 2 levels deep Signed-off-by: Alex Andreev * Add tree view to namespace details Signed-off-by: Alex Andreev * Expand all nodes by default Signed-off-by: Alex Andreev * Add links to the tree items Signed-off-by: Alex Andreev * Initial label styling Signed-off-by: Alex Andreev * Label and group styling Signed-off-by: Alex Andreev * Remove fontSize attr from SvgIcon Signed-off-by: Alex Andreev * Styling subnamespace badge Signed-off-by: Alex Andreev * Expand and collapse tree nodes Signed-off-by: Alex Andreev * Testing clicking plus icon Signed-off-by: Alex Andreev * Restyling subnamespace badge Signed-off-by: Alex Andreev * Adding tooltip for subnamespace badge Signed-off-by: Alex Andreev * Linter fixes Signed-off-by: Alex Andreev * Fix linter harder Signed-off-by: Alex Andreev * Replace CloseIcon with semi-transparent MinusIcon Signed-off-by: Alex Andreev * Styling TreeView inside scss module Signed-off-by: Alex Andreev * Move isSubnamespace method inside API Signed-off-by: Alex Andreev * Extract nodeId to avoid repeating Signed-off-by: Alex Andreev * Rename Icon components Signed-off-by: Alex Andreev * Clean up tests from boilderplate Signed-off-by: Alex Andreev * Subnamespace badge style fixes Signed-off-by: Alex Andreev * Linter fix Signed-off-by: Alex Andreev * Use font-size: x-small instead of rem units Signed-off-by: Alex Andreev * Move subnamespace badge show check to parent Signed-off-by: Alex Andreev * Use prevDefault util Signed-off-by: Alex Andreev * Refactor: move tree build logic to store Signed-off-by: Alex Andreev * Linter fixes Signed-off-by: Alex Andreev * Refactor hierarchicalNamespacesInjectable Signed-off-by: Alex Andreev * Add tests for getNamespaceTree() function Signed-off-by: Alex Andreev * Use Subnamespace badge in namespace list (#7132) Signed-off-by: Alex Andreev --------- Signed-off-by: Alex Andreev --- .../common/k8s-api/endpoints/namespace.api.ts | 14 + .../namespace-tree-view.test.tsx.snap | 937 ++++++++++++++++++ .../hierarchical-namespaces.injectable.ts | 18 + .../+namespaces/namespace-details.tsx | 9 + .../+namespaces/namespace-store.test.ts | 205 ++++ .../namespace-tree-view.module.scss | 14 + .../+namespaces/namespace-tree-view.test.tsx | 334 +++++++ .../+namespaces/namespace-tree-view.tsx | 106 ++ .../components/+namespaces/namespaces.scss | 4 + .../renderer/components/+namespaces/route.tsx | 8 +- .../renderer/components/+namespaces/store.ts | 16 + .../subnamespace-badge.module.scss | 11 + .../+namespaces/subnamespace-badge.tsx | 31 + 13 files changed, 1706 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/renderer/components/+namespaces/__snapshots__/namespace-tree-view.test.tsx.snap create mode 100644 packages/core/src/renderer/components/+namespaces/hierarchical-namespaces.injectable.ts create mode 100644 packages/core/src/renderer/components/+namespaces/namespace-store.test.ts create mode 100644 packages/core/src/renderer/components/+namespaces/namespace-tree-view.module.scss create mode 100644 packages/core/src/renderer/components/+namespaces/namespace-tree-view.test.tsx create mode 100644 packages/core/src/renderer/components/+namespaces/namespace-tree-view.tsx create mode 100644 packages/core/src/renderer/components/+namespaces/subnamespace-badge.module.scss create mode 100644 packages/core/src/renderer/components/+namespaces/subnamespace-badge.tsx diff --git a/packages/core/src/common/k8s-api/endpoints/namespace.api.ts b/packages/core/src/common/k8s-api/endpoints/namespace.api.ts index 3de24d3df9..04f4501313 100644 --- a/packages/core/src/common/k8s-api/endpoints/namespace.api.ts +++ b/packages/core/src/common/k8s-api/endpoints/namespace.api.ts @@ -33,6 +33,20 @@ export class Namespace extends KubeObject< getStatus() { return this.status?.phase ?? "-"; } + + isSubnamespace() { + return this.getAnnotations().find(annotation => annotation.includes("hnc.x-k8s.io/subnamespace-of")); + } + + isChildOf(parentName: string) { + return this.getLabels().find(label => label === `${parentName}.tree.hnc.x-k8s.io/depth=1`); + } + + isControlledByHNC() { + const hierarchicalNamesaceControllerLabel = "hnc.x-k8s.io/included-namespace=true"; + + return this.getLabels().find(label => label === hierarchicalNamesaceControllerLabel); + } } export class NamespaceApi extends KubeApi { diff --git a/packages/core/src/renderer/components/+namespaces/__snapshots__/namespace-tree-view.test.tsx.snap b/packages/core/src/renderer/components/+namespaces/__snapshots__/namespace-tree-view.test.tsx.snap new file mode 100644 index 0000000000..206c68cd27 --- /dev/null +++ b/packages/core/src/renderer/components/+namespaces/__snapshots__/namespace-tree-view.test.tsx.snap @@ -0,0 +1,937 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` collapses item by clicking minus button 1`] = ` + +
+
+
+ Tree View +
+
    +
  • +
    +
    + +
    +
    + levels-deep + +
    +
    +
      +
      +
      +
    • +
      +
      +
      + +
      +
      +
      + level-deep-child-a + +
      +
      +
    • + +
      +
      +
    +
  • +
+
+
+ +`; + +exports[` expands item by clicking plus button 1`] = ` + +
+
+
+ Tree View +
+
    +
  • +
    +
    + +
    +
    + levels-deep + +
    +
    +
      +
      +
      +
    • +
      +
      +
      + +
      +
      +
      + level-deep-child-a + +
      +
      +
    • +
    • +
      +
      + +
      +
      + level-deep-child-b + +
      +
      +
        +
        +
        +
      • +
        +
        +
        + +
        +
        +
        + level-deep-subchild-a + +
        +
        +
      • +
        +
        +
      +
    • +
      +
      +
    +
  • +
+
+
+ +`; + +exports[` renders 2 levels deep 1`] = ` + +
+
+
+ Tree View +
+
    +
  • +
    +
    + +
    +
    + levels-deep + +
    +
    +
      +
      +
      +
    • +
      +
      +
      + +
      +
      +
      + level-deep-child-a + +
      +
      +
    • +
    • +
      +
      + +
      +
      + level-deep-child-b + +
      +
      +
        +
        +
        +
      • +
        +
        +
        + +
        +
        +
        + level-deep-subchild-a + +
        +
        +
      • +
        +
        +
      +
    • +
      +
      +
    +
  • +
+
+
+ +`; + +exports[` renders namespace with 2 children namespaces 1`] = ` + +
+
+
+ Tree View +
+
    +
  • +
    +
    + +
    +
    + acme-org + +
    +
    +
      +
      +
      +
    • +
      +
      +
      + +
      +
      +
      + team-a + +
      +
      +
    • +
    • +
      +
      +
      + +
      +
      +
      + team-b + +
      +
      +
    • +
      +
      +
    +
  • +
+
+
+ +`; + +exports[` renders namespace with children namespaces and a subnamespace 1`] = ` + +
+
+
+ Tree View +
+
    +
  • +
    +
    + +
    +
    + org-a + +
    +
    +
      +
      +
      +
    • +
      +
      +
      + +
      +
      +
      + team-c + +
      +
      +
    • +
    • +
      +
      +
      + +
      +
      +
      + service-1 + + + S + +
      +
      +
    • +
      +
      +
    +
  • +
+
+
+ +`; + +exports[` renders one namespace without children 1`] = ` + +
+
+
+ Tree View +
+
    +
  • +
    +
    +
    + +
    +
    +
    + single-root + +
    +
    +
  • +
+
+
+ +`; diff --git a/packages/core/src/renderer/components/+namespaces/hierarchical-namespaces.injectable.ts b/packages/core/src/renderer/components/+namespaces/hierarchical-namespaces.injectable.ts new file mode 100644 index 0000000000..5c473f29dc --- /dev/null +++ b/packages/core/src/renderer/components/+namespaces/hierarchical-namespaces.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import namespaceStoreInjectable from "./store.injectable"; + +const hierarchicalNamespacesInjectable = getInjectable({ + id: "hierarchical-namespaces", + + instantiate: (di) => { + const namespaceStore = di.inject(namespaceStoreInjectable); + + return namespaceStore.items.filter(item => item.isControlledByHNC()); + }, +}); + +export default hierarchicalNamespacesInjectable; diff --git a/packages/core/src/renderer/components/+namespaces/namespace-details.tsx b/packages/core/src/renderer/components/+namespaces/namespace-details.tsx index 33712e9b2b..d38cab94ba 100644 --- a/packages/core/src/renderer/components/+namespaces/namespace-details.tsx +++ b/packages/core/src/renderer/components/+namespaces/namespace-details.tsx @@ -26,6 +26,9 @@ import limitRangeStoreInjectable from "../+config-limit-ranges/store.injectable" import resourceQuotaStoreInjectable from "../+config-resource-quotas/store.injectable"; import type { Logger } from "../../../common/logger"; import loggerInjectable from "../../../common/logger.injectable"; +import { NamespaceTreeView } from "./namespace-tree-view"; +import namespaceStoreInjectable from "./store.injectable"; +import type { NamespaceStore } from "./store"; export interface NamespaceDetailsProps extends KubeObjectDetailsProps { } @@ -35,6 +38,7 @@ interface Dependencies { getDetailsUrl: GetDetailsUrl; resourceQuotaStore: ResourceQuotaStore; limitRangeStore: LimitRangeStore; + namespaceStore: NamespaceStore; logger: Logger; } @@ -103,6 +107,10 @@ class NonInjectedNamespaceDetails extends React.Component ))} + + {namespace.isControlledByHNC() && ( + + )} ); } @@ -115,6 +123,7 @@ export const NamespaceDetails = withInjectables, annotations?: Record): Namespace { + return new Namespace({ + apiVersion: "v1", + kind: "Namespace", + metadata: { + name, + resourceVersion: "1", + selfLink: `/api/v1/namespaces/${name}`, + uid: `${name}`, + labels: { + ...labels, + }, + annotations: { + ...annotations, + }, + }, + }); +} + +const singleRoot = createNamespace("single-root", { + "hnc.x-k8s.io/included-namespace": "true", +}); + +const acmeGroup = createNamespace("acme-org", { + "hnc.x-k8s.io/included-namespace": "true", +}); + +const orgA = createNamespace("org-a", { + "hnc.x-k8s.io/included-namespace": "true", +}); + +const teamA = createNamespace("team-a", { + "hnc.x-k8s.io/included-namespace": "true", + "acme-org.tree.hnc.x-k8s.io/depth": "1", + "kubernetes.io/metadata.name": "team-a", + "team-a.tree.hnc.x-k8s.io/depth": "0", +}); + +const teamB = createNamespace("team-b", { + "hnc.x-k8s.io/included-namespace": "true", + "acme-org.tree.hnc.x-k8s.io/depth": "1", + "kubernetes.io/metadata.name": "team-b", + "team-b.tree.hnc.x-k8s.io/depth": "0", +}); + +const teamC = createNamespace("team-c", { + "hnc.x-k8s.io/included-namespace": "true", + "org-a.tree.hnc.x-k8s.io/depth": "1", + "kubernetes.io/metadata.name": "team-c", + "team-c.tree.hnc.x-k8s.io/depth": "0", +}); + +const service1 = createNamespace("service-1", { + "hnc.x-k8s.io/included-namespace": "true", + "org-a.tree.hnc.x-k8s.io/depth": "1", + "kubernetes.io/metadata.name": "team-c", + "service-1.tree.hnc.x-k8s.io/depth": "0", +}, { + "hnc.x-k8s.io/subnamespace-of": "org-a", +}); + +const levelsDeep = createNamespace("levels-deep", { + "hnc.x-k8s.io/included-namespace": "true", +}); + +const levelDeepChildA = createNamespace("level-deep-child-a", { + "hnc.x-k8s.io/included-namespace": "true", + "levels-deep.tree.hnc.x-k8s.io/depth": "1", + "level-deep-child-a.tree.hnc.x-k8s.io/depth": "0", +}); + +const levelDeepChildB = createNamespace("level-deep-child-b", { + "hnc.x-k8s.io/included-namespace": "true", + "levels-deep.tree.hnc.x-k8s.io/depth": "1", + "level-deep-child-b.tree.hnc.x-k8s.io/depth": "0", +}); + +const levelDeepSubChildA = createNamespace("level-deep-subchild-a", { + "hnc.x-k8s.io/included-namespace": "true", + "levels-deep.tree.hnc.x-k8s.io/depth": "2", + "level-deep-child-b.tree.hnc.x-k8s.io/depth": "1", + "level-deep-subchild-a.tree.hnc.x-k8s.io/depth": "0", +}); + + +describe("NamespaceStore", () => { + let di: DiContainer; + let namespaceStore: NamespaceStore; + + beforeEach(async () => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override(directoryForUserDataInjectable, () => "/some-user-store-path"); + di.override(directoryForKubeConfigsInjectable, () => "/some-kube-configs"); + di.override(storesAndApisCanBeCreatedInjectable, () => true); + + const createCluster = di.inject(createClusterInjectable); + + di.override(hostedClusterInjectable, () => createCluster({ + contextName: "some-context-name", + id: "some-cluster-id", + kubeConfigPath: "/some-path-to-a-kubeconfig", + }, { + clusterServerUrl: "https://localhost:8080", + })); + + namespaceStore = di.inject(namespaceStoreInjectable); + + namespaceStore.items = observable.array([ + acmeGroup, + orgA, + teamA, + teamB, + teamC, + service1, + levelsDeep, + levelDeepChildA, + levelDeepChildB, + levelDeepSubChildA, + ]); + }); + + it("returns tree for single node", () => { + const tree = namespaceStore.getNamespaceTree(service1); + + expect(tree).toEqual({ + id: "service-1", + namespace: service1, + children: [], + }); + }); + + it("returns tree for namespace not listed in store", () => { + const tree = namespaceStore.getNamespaceTree(singleRoot); + + expect(tree).toEqual({ + id: "single-root", + namespace: singleRoot, + children: [], + }); + }); + + it("return tree for namespace with children", () => { + const tree = namespaceStore.getNamespaceTree(acmeGroup); + + expect(tree).toEqual({ + id: "acme-org", + namespace: acmeGroup, + children: [ + { + id: "team-a", + namespace: teamA, + children: [], + }, + { + id: "team-b", + namespace: teamB, + children: [], + }, + ], + }); + }); + + it("return tree for namespace with deep nested children", () => { + const tree = namespaceStore.getNamespaceTree(levelsDeep); + + expect(tree).toEqual({ + id: "levels-deep", + namespace: levelsDeep, + children: [ + { + id: "level-deep-child-a", + namespace: levelDeepChildA, + children: [], + }, + { + id: "level-deep-child-b", + namespace: levelDeepChildB, + children: [{ + id: "level-deep-subchild-a", + namespace: levelDeepSubChildA, + children: [], + }], + }, + ], + }); + }); +}); diff --git a/packages/core/src/renderer/components/+namespaces/namespace-tree-view.module.scss b/packages/core/src/renderer/components/+namespaces/namespace-tree-view.module.scss new file mode 100644 index 0000000000..b5bc104c03 --- /dev/null +++ b/packages/core/src/renderer/components/+namespaces/namespace-tree-view.module.scss @@ -0,0 +1,14 @@ +.TreeView { + .group { + margin-inline-start: var(--margin); + padding-inline-start: calc(var(--padding) * 2); + border-inline-start: 1px dashed var(--borderColor); + } + + .label { + font-size: inherit; + line-height: 1.8; + cursor: default; + background-color: transparent!important; + } +} \ No newline at end of file diff --git a/packages/core/src/renderer/components/+namespaces/namespace-tree-view.test.tsx b/packages/core/src/renderer/components/+namespaces/namespace-tree-view.test.tsx new file mode 100644 index 0000000000..783466ffe3 --- /dev/null +++ b/packages/core/src/renderer/components/+namespaces/namespace-tree-view.test.tsx @@ -0,0 +1,334 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { DiContainer } from "@ogre-tools/injectable"; +import { fireEvent } from "@testing-library/react"; +import React from "react"; +import { Namespace } from "../../../common/k8s-api/endpoints"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import type { DiRender } from "../test-utils/renderFor"; +import { renderFor } from "../test-utils/renderFor"; +import hierarchicalNamespacesInjectable from "./hierarchical-namespaces.injectable"; +import { NamespaceTreeView } from "./namespace-tree-view"; +import type { NamespaceTree } from "./store"; + +jest.mock("react-router-dom", () => ({ + Link: ({ children }: { children: React.ReactNode }) => children, +})); + +function createNamespace(name: string, labels?: Record, annotations?: Record): Namespace { + return new Namespace({ + apiVersion: "v1", + kind: "Namespace", + metadata: { + name, + resourceVersion: "1", + selfLink: `/api/v1/namespaces/${name}`, + uid: `${name}`, + labels: { + ...labels, + }, + annotations: { + ...annotations, + }, + }, + }); +} + +const singleRoot = createNamespace("single-root", { + "hnc.x-k8s.io/included-namespace": "true", +}); + +const acmeGroup = createNamespace("acme-org", { + "hnc.x-k8s.io/included-namespace": "true", +}); + +const orgA = createNamespace("org-a", { + "hnc.x-k8s.io/included-namespace": "true", +}); + +const teamA = createNamespace("team-a", { + "hnc.x-k8s.io/included-namespace": "true", + "acme-org.tree.hnc.x-k8s.io/depth": "1", + "kubernetes.io/metadata.name": "team-a", + "team-a.tree.hnc.x-k8s.io/depth": "0", +}); + +const teamB = createNamespace("team-b", { + "hnc.x-k8s.io/included-namespace": "true", + "acme-org.tree.hnc.x-k8s.io/depth": "1", + "kubernetes.io/metadata.name": "team-b", + "team-b.tree.hnc.x-k8s.io/depth": "0", +}); + +const teamC = createNamespace("team-c", { + "hnc.x-k8s.io/included-namespace": "true", + "org-a.tree.hnc.x-k8s.io/depth": "1", + "kubernetes.io/metadata.name": "team-c", + "team-c.tree.hnc.x-k8s.io/depth": "0", +}); + +const service1 = createNamespace("service-1", { + "hnc.x-k8s.io/included-namespace": "true", + "org-a.tree.hnc.x-k8s.io/depth": "1", + "kubernetes.io/metadata.name": "team-c", + "service-1.tree.hnc.x-k8s.io/depth": "0", +}, { + "hnc.x-k8s.io/subnamespace-of": "org-a", +}); + +const levelsDeep = createNamespace("levels-deep", { + "hnc.x-k8s.io/included-namespace": "true", +}); + +const levelDeepChildA = createNamespace("level-deep-child-a", { + "hnc.x-k8s.io/included-namespace": "true", + "levels-deep.tree.hnc.x-k8s.io/depth": "1", + "level-deep-child-a.tree.hnc.x-k8s.io/depth": "0", +}); + +const levelDeepChildB = createNamespace("level-deep-child-b", { + "hnc.x-k8s.io/included-namespace": "true", + "levels-deep.tree.hnc.x-k8s.io/depth": "1", + "level-deep-child-b.tree.hnc.x-k8s.io/depth": "0", +}); + +const levelDeepSubChildA = createNamespace("level-deep-subchild-a", { + "hnc.x-k8s.io/included-namespace": "true", + "levels-deep.tree.hnc.x-k8s.io/depth": "2", + "level-deep-child-b.tree.hnc.x-k8s.io/depth": "1", + "level-deep-subchild-a.tree.hnc.x-k8s.io/depth": "0", +}); + +describe("", () => { + let di: DiContainer; + let render: DiRender; + + beforeEach(async () => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override(hierarchicalNamespacesInjectable, () => [ + acmeGroup, + orgA, + teamA, + teamB, + teamC, + service1, + levelsDeep, + levelDeepChildA, + levelDeepChildB, + levelDeepSubChildA, + ]); + + render = renderFor(di); + }); + + it("renders one namespace without children", () => { + const tree: NamespaceTree = { + id: "single-root", + namespace: singleRoot, + }; + + const result = render(); + + expect(result.baseElement).toMatchSnapshot(); + }); + + it("renders namespace with 2 children namespaces", () => { + const tree: NamespaceTree = { + id: "acme-org", + namespace: acmeGroup, + children: [ + { + id: "team-a", + namespace: teamA, + }, + { + id: "team-b", + namespace: teamB, + }, + ], + }; + + const result = render(); + + expect(result.baseElement).toMatchSnapshot(); + }); + + it("renders namespace with children namespaces and a subnamespace", () => { + const tree: NamespaceTree = { + id: "org-a", + namespace: orgA, + children: [ + { + id: "team-c", + namespace: teamC, + }, + { + id: "service-1", + namespace: service1, + }, + ], + }; + const result = render(); + + expect(result.baseElement).toMatchSnapshot(); + }); + + it("renders an indicator badge for the subnamespace", () => { + const tree: NamespaceTree = { + id: "org-a", + namespace: orgA, + children: [ + { + id: "team-c", + namespace: teamC, + }, + { + id: "service-1", + namespace: service1, + }, + ], + }; + const result = render(); + + expect(result.getByTestId("namespace-details-badge-for-service-1")).toBeInTheDocument(); + }); + + it("does not render an indicator badge for the true namespace", () => { + const tree: NamespaceTree = { + id: "org-a", + namespace: orgA, + children: [ + { + id: "team-c", + namespace: teamC, + }, + { + id: "service-1", + namespace: service1, + }, + ], + }; + const result = render(); + const trueNamespace = result.getByTestId("namespace-team-c"); + + expect(trueNamespace.querySelector("[data-testid='namespace-details-badge-for-team-c']")).toBeNull(); + }); + + it("renders 2 levels deep", () => { + const tree: NamespaceTree = { + id: "levels-deep", + namespace: levelsDeep, + children: [ + { + id: "level-deep-child-a", + namespace: levelDeepChildA, + }, + { + id: "level-deep-child-b", + namespace: levelDeepChildB, + children: [{ + id: "level-deep-subchild-a", + namespace: levelDeepSubChildA, + }], + }, + ], + }; + const result = render(); + + expect(result.baseElement).toMatchSnapshot(); + }); + + it("expands children items by default", () => { + const tree: NamespaceTree = { + id: "levels-deep", + namespace: levelsDeep, + children: [ + { + id: "level-deep-child-a", + namespace: levelDeepChildA, + }, + { + id: "level-deep-child-b", + namespace: levelDeepChildB, + children: [{ + id: "level-deep-subchild-a", + namespace: levelDeepSubChildA, + }], + }, + ], + }; + const result = render(); + const deepest = result.getByTestId("namespace-level-deep-child-b"); + + expect(deepest).toHaveAttribute("aria-expanded", "true"); + }); + + it("collapses item by clicking minus button", () => { + const tree: NamespaceTree = { + id: "levels-deep", + namespace: levelsDeep, + children: [ + { + id: "level-deep-child-a", + namespace: levelDeepChildA, + }, + { + id: "level-deep-child-b", + namespace: levelDeepChildB, + children: [{ + id: "level-deep-subchild-a", + namespace: levelDeepSubChildA, + }], + }, + ], + }; + const result = render(); + const levelB = result.getByTestId("namespace-level-deep-child-b"); + const minusButton = levelB.querySelector("[data-testid='minus-square']"); + + if (minusButton) { + fireEvent.click(minusButton); + } + + expect(result.baseElement).toMatchSnapshot(); + }); + + it("expands item by clicking plus button", () => { + const tree: NamespaceTree = { + id: "levels-deep", + namespace: levelsDeep, + children: [ + { + id: "level-deep-child-a", + namespace: levelDeepChildA, + }, + { + id: "level-deep-child-b", + namespace: levelDeepChildB, + children: [{ + id: "level-deep-subchild-a", + namespace: levelDeepSubChildA, + }], + }, + ], + }; + const result = render(); + const levelB = result.getByTestId("namespace-level-deep-child-b"); + const minusButton = levelB.querySelector("[data-testid='minus-square']"); + + if (minusButton) { + fireEvent.click(minusButton); + } + + const plusButton = levelB.querySelector("[data-testid='plus-square']"); + + if (plusButton) { + fireEvent.click(plusButton); + } + + expect(result.baseElement).toMatchSnapshot(); + }); +}); diff --git a/packages/core/src/renderer/components/+namespaces/namespace-tree-view.tsx b/packages/core/src/renderer/components/+namespaces/namespace-tree-view.tsx new file mode 100644 index 0000000000..8d69e1aba7 --- /dev/null +++ b/packages/core/src/renderer/components/+namespaces/namespace-tree-view.tsx @@ -0,0 +1,106 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import styles from "./namespace-tree-view.module.scss"; + +import { SvgIcon } from "@material-ui/core"; +import { TreeItem, TreeView } from "@material-ui/lab"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import React from "react"; +import { Link } from "react-router-dom"; +import type { Namespace } from "../../../common/k8s-api/endpoints"; +import { DrawerTitle } from "../drawer"; +import type { GetDetailsUrl } from "../kube-detail-params/get-details-url.injectable"; +import getDetailsUrlInjectable from "../kube-detail-params/get-details-url.injectable"; +import { SubnamespaceBadge } from "./subnamespace-badge"; +import hierarchicalNamespacesInjectable from "./hierarchical-namespaces.injectable"; +import { prevDefault } from "../../utils"; +import type { NamespaceTree } from "./store"; + +interface NamespaceTreeViewProps { + tree: NamespaceTree; +} + +interface Dependencies { + namespaces: Namespace[]; + getDetailsUrl: GetDetailsUrl; +} + +function NonInjectableNamespaceTreeView({ tree, namespaces, getDetailsUrl }: Dependencies & NamespaceTreeViewProps) { + const [expandedItems, setExpandedItems] = React.useState(namespaces.map(ns => ns.getId())); + const classes = { group: styles.group, label: styles.label }; + + function renderTree(nodes: NamespaceTree) { + return ( + toggleNode(nodes.id))} + label={( + <> + + {nodes.namespace.getName()} + + {" "} + {nodes.namespace.isSubnamespace() && ( + + )} + + )} + > + {Array.isArray(nodes.children) ? nodes.children.map((node) => renderTree(node)) : null} + + ); + } + + function toggleNode(id: string) { + if (expandedItems.includes(id)) { + setExpandedItems(expandedItems.filter(item => item !== id)); + } else { + setExpandedItems([...expandedItems, id]); + } + } + + return ( +
+ Tree View + } + defaultExpandIcon={} + defaultEndIcon={(
)} + expanded={expandedItems} + > + {renderTree(tree)} +
+
+ ); +} + +function MinusSquareIcon() { + return ( + + + + ); +} + +function PlusSquareIcon() { + return ( + + + + ); +} + +export const NamespaceTreeView = withInjectables(NonInjectableNamespaceTreeView, { + getProps: (di, props) => ({ + namespaces: di.inject(hierarchicalNamespacesInjectable), + getDetailsUrl: di.inject(getDetailsUrlInjectable), + ...props, + }), +}); diff --git a/packages/core/src/renderer/components/+namespaces/namespaces.scss b/packages/core/src/renderer/components/+namespaces/namespaces.scss index 8636d2f5ad..8aabb06a4a 100644 --- a/packages/core/src/renderer/components/+namespaces/namespaces.scss +++ b/packages/core/src/renderer/components/+namespaces/namespaces.scss @@ -22,4 +22,8 @@ @include namespaceStatus; } } + + .subnamespaceBadge { + margin-inline-start: var(--margin); + } } diff --git a/packages/core/src/renderer/components/+namespaces/route.tsx b/packages/core/src/renderer/components/+namespaces/route.tsx index 7efbfb738e..11e6d97864 100644 --- a/packages/core/src/renderer/components/+namespaces/route.tsx +++ b/packages/core/src/renderer/components/+namespaces/route.tsx @@ -16,6 +16,7 @@ import { withInjectables } from "@ogre-tools/injectable-react"; import namespaceStoreInjectable from "./store.injectable"; import { KubeObjectAge } from "../kube-object/age"; import openAddNamepaceDialogInjectable from "./add-dialog/open.injectable"; +import { SubnamespaceBadge } from "./subnamespace-badge"; enum columnId { name = "name", @@ -55,7 +56,12 @@ const NonInjectedNamespacesRoute = ({ namespaceStore, openAddNamespaceDialog }: { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, ]} renderTableContents={namespace => [ - namespace.getName(), + <> + {namespace.getName()} + {namespace.isSubnamespace() && ( + + )} + , , namespace.getLabels().map(label => ( ; readonly clusterConfiguredAccessibleNamespaces: IComputedValue; @@ -202,6 +208,16 @@ export class NamespaceStore extends KubeObjectStore { this.selectAll(); } + getNamespaceTree(root: Namespace): NamespaceTree { + const children = this.items.filter(namespace => namespace.isChildOf(root.getName())); + + return { + id: root.getId(), + namespace: root, + children: children.map(this.getNamespaceTree), + }; + } + @action async remove(item: Namespace) { await super.remove(item); diff --git a/packages/core/src/renderer/components/+namespaces/subnamespace-badge.module.scss b/packages/core/src/renderer/components/+namespaces/subnamespace-badge.module.scss new file mode 100644 index 0000000000..a557fdbba2 --- /dev/null +++ b/packages/core/src/renderer/components/+namespaces/subnamespace-badge.module.scss @@ -0,0 +1,11 @@ +.subnamespaceBadge { + border-radius: 3px; + padding: 0px 4px; + border: 1px solid cadetblue; + color: cadetblue; + display: inline-flex; + font-size: x-small; + font-weight: bold; + height: 16px; + align-items: center; +} \ No newline at end of file diff --git a/packages/core/src/renderer/components/+namespaces/subnamespace-badge.tsx b/packages/core/src/renderer/components/+namespaces/subnamespace-badge.tsx new file mode 100644 index 0000000000..4ae652acc6 --- /dev/null +++ b/packages/core/src/renderer/components/+namespaces/subnamespace-badge.tsx @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import styles from "./subnamespace-badge.module.scss"; + +import React from "react"; +import { Tooltip } from "../tooltip"; +import { cssNames } from "../../utils"; + +interface SubnamespaceBadgeProps extends React.HTMLAttributes { + id: string; +} + +export function SubnamespaceBadge({ id, className, ...other }: SubnamespaceBadgeProps) { + return ( + <> + + S + + + Subnamespace + + + ); +}