diff --git a/.bundled-extensions.json b/.bundled-extensions.json deleted file mode 100644 index f521c01dfd..0000000000 --- a/.bundled-extensions.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extensions": [ - "pod-menu", - "node-menu", - "metrics-cluster-feature", - "kube-object-event-status" - ] -} diff --git a/.eslintrc.js b/.eslintrc.js index 733f644615..52ba26dcb3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -12,11 +12,21 @@ module.exports = { "**/static/**/*", "**/site/**/*", "extensions/*/*.tgz", + "build/webpack/**/*", ], settings: { react: { version: packageJson.devDependencies.react || "detect", }, + "import/parsers": { + "@typescript-eslint/parser": [".ts", ".tsx"], + }, + "import/resolver": { + "typescript": { + "alwaysTryTypes": true, + "project": "./tsconfig.json", + }, + }, }, overrides: [ { diff --git a/.github/workflows/add-to-project-board.yaml b/.github/workflows/add-to-project-board.yaml index 8f23cd406c..5ebb3de16e 100644 --- a/.github/workflows/add-to-project-board.yaml +++ b/.github/workflows/add-to-project-board.yaml @@ -13,21 +13,21 @@ jobs: runs-on: ubuntu-latest name: Add Card to Project(s) steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Get Event Type - run: echo $GITHUB_EVENT_NAME - - name: Assign NEW issues to project 1 - uses: ./.github/actions/add-card-to-project - if: github.event_name == 'issues' && github.event.action == 'opened' - with: - project: 'https://github.com/orgs/lensapp/projects/1' - column_name: 'Backlog' - card_position: 'bottom' - - name: Assign NEW pull requests to project 1 - uses: ./.github/actions/add-card-to-project - if: github.event_name == 'pull_request_target' && github.event.action == 'opened' - with: - project: 'https://github.com/orgs/lensapp/projects/1' - column_name: 'PRs' - card_position: 'bottom' + - name: Checkout + uses: actions/checkout@v3 + - name: Get Event Type + run: echo $GITHUB_EVENT_NAME + - name: Assign NEW issues to project 1 + uses: ./.github/actions/add-card-to-project + if: github.event_name == 'issues' && github.event.action == 'opened' + with: + project: "https://github.com/orgs/lensapp/projects/1" + column_name: "Backlog" + card_position: "bottom" + - name: Assign NEW pull requests to project 1 + uses: ./.github/actions/add-card-to-project + if: github.event_name == 'pull_request_target' && github.event.action == 'opened' + with: + project: "https://github.com/orgs/lensapp/projects/1" + column_name: "PRs" + card_position: "bottom" diff --git a/.github/workflows/bump-master-version.yaml b/.github/workflows/bump-master-version.yaml new file mode 100644 index 0000000000..7c4d06ad2b --- /dev/null +++ b/.github/workflows/bump-master-version.yaml @@ -0,0 +1,35 @@ +name: Bump Version on master +on: + pull_request: + types: + - closed + branches: + - master + workflow_dispatch: + +jobs: + release: + name: Release + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release') }} + steps: + - name: Checkout Release from lens + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Install dependencies + run: | + npm i --location=global semver + - name: Bump version to first alpha of next minor version + run: | + NEW_VERSION=$(cat package.json | jq .version --raw-output| xargs semver -i preminor --preid alpha) + cat package.json | jq --arg new_version "$NEW_VERSION" '.version = $new_version' > new-package.json + mv new-package.json package.json + - uses: peter-evans/create-pull-request@v4 + with: + add-paths: package.json + commit-message: Update package.json version to next preminor because of recent release + signoff: true + delete-branch: true + title: Update version to next preminor + labels: chore diff --git a/.github/workflows/check-docs.yml b/.github/workflows/check-docs.yml index 7418a86269..defc5f3ca1 100644 --- a/.github/workflows/check-docs.yml +++ b/.github/workflows/check-docs.yml @@ -12,12 +12,12 @@ jobs: node-version: [16.x] steps: - name: Checkout Release from lens - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 - name: Using Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index fc2692be70..e764763208 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,12 +13,12 @@ name: "CodeQL" on: push: - branches: [ master ] + branches: [master] pull_request: # The branches below must be a subset of the branches above - branches: [ master ] + branches: [master] schedule: - - cron: '41 3 * * 2' + - cron: "41 3 * * 2" jobs: analyze: @@ -32,40 +32,40 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'javascript' ] + language: ["javascript"] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] # Learn more: # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed steps: - - name: Checkout repository - uses: actions/checkout@v2 + - name: Checkout repository + uses: actions/checkout@v3 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language + # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language - #- run: | - # make bootstrap - # make release + #- run: | + # make bootstrap + # make release - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/electronegativity.yml b/.github/workflows/electronegativity.yml index 0cc4340f39..6ba06e2721 100644 --- a/.github/workflows/electronegativity.yml +++ b/.github/workflows/electronegativity.yml @@ -10,9 +10,9 @@ jobs: build_job: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - - uses: actions/setup-node@v2 + - uses: actions/setup-node@v3 with: node-version: "16" diff --git a/.github/workflows/license-header.yml b/.github/workflows/license-header.yml index 20b0a01d19..2e33292785 100644 --- a/.github/workflows/license-header.yml +++ b/.github/workflows/license-header.yml @@ -11,18 +11,18 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Golang - uses: actions/setup-go@v2 - with: - go-version: '^1.18.0' - - name: Install addlicense - run: | - export PATH=${PATH}:`go env GOPATH`/bin - go install github.com/google/addlicense@v1.0.0 - - name: Check license headers - run: | - set -e - export PATH=${PATH}:`go env GOPATH`/bin + - uses: actions/checkout@v3 + - name: Set up Golang + uses: actions/setup-go@v2 + with: + go-version: "^1.18.0" + - name: Install addlicense + run: | + export PATH=${PATH}:`go env GOPATH`/bin + go install github.com/google/addlicense@v1.0.0 + - name: Check license headers + run: | + set -e + export PATH=${PATH}:`go env GOPATH`/bin - addlicense -check -l mit -c "OpenLens Authors" src/**/*.?css + addlicense -check -l mit -c "OpenLens Authors" src/**/*.?css diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index ac7ac673ca..e1b0bac244 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -10,12 +10,12 @@ jobs: node-version: [16.x] steps: - name: Checkout Release from lens - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 - name: Using Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2fa32efc65..949a4463cb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,12 +23,12 @@ jobs: python-version: "3.x" - name: Checkout Release from lens - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 - name: Using Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} @@ -61,7 +61,7 @@ jobs: pip install mike - name: Checkout Release from lens - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 @@ -71,7 +71,7 @@ jobs: git config --local user.name "GitHub Action" - name: Using Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} @@ -88,7 +88,7 @@ jobs: - name: Get the release version if: contains(github.ref, 'refs/tags/v') && !github.event.release.prerelease id: get_version - run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} + run: echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT - name: mkdocs deploy new release if: contains(github.ref, 'refs/tags/v') && !github.event.release.prerelease diff --git a/.github/workflows/mkdocs-delete-version.yml b/.github/workflows/mkdocs-delete-version.yml index 0183910d8d..a5ff72b8cb 100644 --- a/.github/workflows/mkdocs-delete-version.yml +++ b/.github/workflows/mkdocs-delete-version.yml @@ -13,26 +13,24 @@ jobs: - name: Set up Python 3.7 uses: actions/setup-python@v2 with: - python-version: '3.x' + python-version: "3.x" - name: Install dependencies run: | python -m pip install --upgrade pip pip install git+https://${{ secrets.GH_TOKEN }}@github.com/lensapp/mkdocs-material-insiders.git pip install mike - + - name: Checkout Release from lens - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 - name: git config run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" - name: mkdocs delete version run: | - mike delete --push ${{ github.event.inputs.version }} - \ No newline at end of file + mike delete --push ${{ github.event.inputs.version }} diff --git a/.github/workflows/mkdocs-manual.yml b/.github/workflows/mkdocs-manual.yml index 56bfd0c831..6ca8def8e4 100644 --- a/.github/workflows/mkdocs-manual.yml +++ b/.github/workflows/mkdocs-manual.yml @@ -25,13 +25,13 @@ jobs: pip install mike - name: Checkout Version from lens - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 ref: "${{ github.event.inputs.version }}" - name: Using Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} @@ -41,7 +41,7 @@ jobs: yarn typedocs-extensions-api - name: Checkout master branch from lens - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: "master" ref: "master" @@ -53,9 +53,9 @@ jobs: rm -fr ./docs/clusters ./docs/contributing ./docs/faq ./docs/getting-started ./docs/helm ./docs/support ./docs/supporting sed -i '/Protocol Handlers/d' ./mkdocs.yml sed -i '/IPC/d' ./mkdocs.yml - sed -i 's#../../clusters/adding-clusters.md#https://docs.k8slens.dev/latest/clusters/adding-clusters/#g' ./docs/extensions/get-started/your-first-extension.md - sed -i 's#clusters/adding-clusters.md#https://docs.k8slens.dev/latest/clusters/adding-clusters/#g' ./docs/README.md - sed -i 's#../../contributing/README.md#https://docs.k8slens.dev/latest/contributing/#g' ./docs/extensions/guides/generator.md + sed -i 's#../../clusters/adding-clusters.md#https://docs.k8slens.dev/getting-started/add-cluster/#g' ./docs/extensions/get-started/your-first-extension.md + sed -i 's#clusters/adding-clusters.md#https://docs.k8slens.dev//getting-started/adding-clusters/#g' ./docs/README.md + sed -i 's#../../contributing/README.md#https://docs.k8slens.dev/contributing/#g' ./docs/extensions/guides/generator.md - name: git config run: | diff --git a/.github/workflows/mkdocs-set-default-version.yml b/.github/workflows/mkdocs-set-default-version.yml index 694a7af726..a4bcf5fbb2 100644 --- a/.github/workflows/mkdocs-set-default-version.yml +++ b/.github/workflows/mkdocs-set-default-version.yml @@ -13,26 +13,24 @@ jobs: - name: Set up Python 3.7 uses: actions/setup-python@v2 with: - python-version: '3.x' + python-version: "3.x" - name: Install dependencies run: | python -m pip install --upgrade pip pip install git+https://${{ secrets.GH_TOKEN }}@github.com/lensapp/mkdocs-material-insiders.git pip install mike - + - name: Checkout Release from lens - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 - name: git config run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" - name: mkdocs update default version run: | - mike set-default --push ${{ github.event.inputs.version }} - \ No newline at end of file + mike set-default --push ${{ github.event.inputs.version }} diff --git a/.github/workflows/publish-master-npm.yml b/.github/workflows/publish-master-npm.yml index 31f86ae6f0..620245dc70 100644 --- a/.github/workflows/publish-master-npm.yml +++ b/.github/workflows/publish-master-npm.yml @@ -17,12 +17,12 @@ jobs: node-version: [16.x] steps: - name: Checkout Release - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 - name: Using Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} diff --git a/.github/workflows/publish-release-npm.yml b/.github/workflows/publish-release-npm.yml index becca1f790..0ce928e3d1 100644 --- a/.github/workflows/publish-release-npm.yml +++ b/.github/workflows/publish-release-npm.yml @@ -12,12 +12,12 @@ jobs: node-version: [16.x] steps: - name: Checkout Release - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 - name: Using Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml deleted file mode 100644 index ec49fec6e5..0000000000 --- a/.github/workflows/release-drafter.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Release Drafter - -on: - push: - # branches to consider in the event; optional, defaults to all - branches: - - master - -jobs: - update_release_draft: - runs-on: ubuntu-latest - steps: - # Drafts your next Release notes as Pull Requests are merged into "master" - - uses: release-drafter/release-drafter@v5 - env: - GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..f66347a93f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,30 @@ +name: Release Open Lens +on: + pull_request: + types: + - closed + branches: + - master + - release/v*.* +jobs: + release: + name: Release + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release') }} + steps: + - name: Checkout Release from lens + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: butlerlogic/action-autotag@stable + id: tagger + with: + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + tag_prefix: "v" + - uses: ncipollo/release-action@v1 + if: ${{ steps.tagger.outputs.tagname != '' }} + with: + name: ${{ steps.tagger.outputs.tagname }} + commit: master + tag: ${{ steps.tagger.outputs.tagname }} + body: ${{ github.event.pull_request.body }} diff --git a/.github/workflows/require-milestone.yml b/.github/workflows/require-milestone.yml new file mode 100644 index 0000000000..8fc2c28ba2 --- /dev/null +++ b/.github/workflows/require-milestone.yml @@ -0,0 +1,14 @@ +name: Require Milestone +on: + pull_request: + types: [opened, edited, synchronize] +jobs: + milestone: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Require Milestone + run: | + exit $(gh pr view ${{ github.event.pull_request.number }} --json milestone | jq 'if .milestone == null then 1 else 0 end') + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9aaeec71f6..ee1ab58970 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,17 +7,18 @@ on: branches: - master jobs: - build: - name: Test + test: + name: ${{ matrix.type }} tests on ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-20.04, macos-11, windows-2019] + type: [unit, smoke] node-version: [16.x] steps: - name: Checkout Release from lens - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 @@ -27,15 +28,16 @@ jobs: echo -e "$(ip addr show eth0 | grep "inet\b" | awk '{print $2}' | cut -d/ -f1)\t$(hostname -f) $(hostname -s)" | sudo tee -a /etc/hosts - name: Using Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} - name: Get yarn cache directory path id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" + shell: bash + run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - - uses: actions/cache@v2 + - uses: actions/cache@v3 id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -51,25 +53,16 @@ jobs: retry_on: error command: make node_modules - - run: make build-npm - name: Generate npm package - - - uses: nick-fields/retry@v2 - name: Build bundled extensions - with: - timeout_minutes: 15 - max_attempts: 3 - retry_on: error - command: make -j2 build-extensions - - run: make test name: Run tests + if: ${{ matrix.type == 'unit' }} - run: make test-extensions name: Run In-tree Extension tests + if: ${{ matrix.type == 'unit' }} - run: make ci-validate-dev - if: contains(github.event.pull_request.labels.*.name, 'dependencies') + if: ${{ contains(github.event.pull_request.labels.*.name, 'dependencies') && matrix.type == 'unit' }} name: Validate dev mode will work - name: Install integration test dependencies @@ -77,22 +70,22 @@ jobs: uses: medyagh/setup-minikube@master with: minikube-version: latest - if: runner.os == 'Linux' + if: ${{ runner.os == 'Linux' && matrix.type == 'smoke' }} - run: xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' make integration name: Run Linux integration tests - if: runner.os == 'Linux' + if: ${{ runner.os == 'Linux' && matrix.type == 'smoke' }} - run: make integration name: Run macOS integration tests shell: bash env: ELECTRON_BUILDER_EXTRA_ARGS: "--x64 --arm64" - if: runner.os == 'macOS' + if: ${{ runner.os == 'macOS' && matrix.type == 'smoke' }} - run: make integration name: Run Windows integration tests shell: bash env: ELECTRON_BUILDER_EXTRA_ARGS: "--x64 --ia32" - if: runner.os == 'Windows' + if: ${{ runner.os == 'Windows' && matrix.type == 'smoke' }} diff --git a/.gitignore b/.gitignore index d018f3b251..0a79ea77c1 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ types/extension-renderer-api.d.ts extensions/*/dist docs/extensions/api site/ +build/webpack/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c745f852d..beb082afe9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,3 @@ # Contributing to Lens -See [Contributing to Lens](https://docs.k8slens.dev/latest/contributing/) documentation. +See [Contributing to Lens](https://docs.k8slens.dev/contributing/) documentation. diff --git a/Makefile b/Makefile index d04b3febe1..857f14b2cb 100644 --- a/Makefile +++ b/Makefile @@ -44,7 +44,7 @@ tag-release: scripts/tag-release.sh $(CMD_ARGS) .PHONY: test -test: binaries/client +test: node_modules binaries/client yarn run jest $(or $(CMD_ARGS), "src") .PHONY: integration @@ -53,7 +53,6 @@ integration: build .PHONY: build build: node_modules binaries/client - yarn run npm:fix-build-version $(MAKE) build-extensions -B yarn run build:tray-icons yarn run compile @@ -63,10 +62,6 @@ ifeq "$(DETECTED_OS)" "Windows" endif yarn run electron-builder --publish onTag $(ELECTRON_BUILDER_EXTRA_ARGS) -.PHONY: update-extension-locks -update-extension-locks: - $(foreach dir, $(extensions), (cd $(dir) && rm package-lock.json && ../../node_modules/.bin/npm install --package-lock-only);) - .NOTPARALLEL: $(extension_node_modules) $(extension_node_modules): node_modules cd $(@:/node_modules=) && ../../node_modules/.bin/npm install --no-audit --no-fund --no-save @@ -92,7 +87,7 @@ src/extensions/npm/extensions/dist: src/extensions/npm/extensions/node_modules yarn compile:extension-types src/extensions/npm/extensions/node_modules: src/extensions/npm/extensions/package.json - cd src/extensions/npm/extensions/ && ../../../../node_modules/.bin/npm install --no-audit --no-fund + cd src/extensions/npm/extensions/ && ../../../../node_modules/.bin/npm install --no-audit --no-fund --no-save .PHONY: build-npm build-npm: build-extension-types src/extensions/npm/extensions/__mocks__ diff --git a/README.md b/README.md index 5e97035d0f..f581e90401 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,16 @@ Lens IDE a standalone application for MacOS, Windows and Linux operating systems ## Installation -See [Getting Started](https://docs.k8slens.dev/main/getting-started/install-lens/) page. +See [Getting Started](https://docs.k8slens.dev/getting-started/install-lens/) page. ## Development -See [Development](https://docs.k8slens.dev/latest/contributing/development/) page. +See [Development](https://docs.k8slens.dev/contributing/development/) page. ## Contributing -See [Contributing](https://docs.k8slens.dev/latest/contributing/) page. +See [Contributing](https://docs.k8slens.dev/contributing/) page. + +## License + +See [License](LICENSE). diff --git a/RELEASE_GUIDE.md b/RELEASE_GUIDE.md new file mode 100644 index 0000000000..559385f025 --- /dev/null +++ b/RELEASE_GUIDE.md @@ -0,0 +1,19 @@ +# Release Guide + +Releases for this repository are made via running the `create-release-pr` script defined in the `package.json`. +All releases will be made by creating a PR which bumps the version field in the `package.json` and, if necessary, cherry pick the relavent commits from master. + +## Prerequisites + +- `yarn` +- Running `yarn` (to install all dependencies) +- `gh` (Github's CLI) with a version at least 2.15.0 + +## Steps + +1. If you are making a minor or major release (or prereleases for one) make sure you are on the `master` branch. +1. If you are making a patch release (or a prerelease for one) make sure you are on the `release/v.` branch. +1. Run `yarn create-release-pr `. If you are making a subsequent prerelease release, provide the `--check-commits` flag. +1. If you are checking the commits, type `y` to pick a commit, and `n` to skip it. You will want to skip the commits that were part of previous prerelease releases. +1. Once the PR is created, approved, and then merged the `Release Open Lens` workflow will create a tag and release for you. +1. If you are making a major or minor release, create a `release/v.` branch and push it to `origin` so that future patch releases can be made from it. diff --git a/__mocks__/windowMock.ts b/__mocks__/windowMock.ts deleted file mode 100644 index bcc3da05a6..0000000000 --- a/__mocks__/windowMock.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -/** - * Mock the global window variable - */ -export function mockWindow() { - Object.defineProperty(window, "requestIdleCallback", { - writable: true, - value: jest.fn().mockImplementation(callback => callback()), - }); - - Object.defineProperty(window, "cancelIdleCallback", { - writable: true, - value: jest.fn(), - }); -} diff --git a/build/download_binaries.ts b/build/download_binaries.ts index ff26a8fb2b..826c713fd3 100644 --- a/build/download_binaries.ts +++ b/build/download_binaries.ts @@ -8,18 +8,30 @@ import { open } from "fs/promises"; import type { WriteStream } from "fs-extra"; import { constants, ensureDir, unlink } from "fs-extra"; import path from "path"; -import fetch from "node-fetch"; +import type * as FetchModule from "node-fetch"; import { promisify } from "util"; import { pipeline as _pipeline, Transform, Writable } from "stream"; import type { SingleBar } from "cli-progress"; import { MultiBar } from "cli-progress"; import { extract } from "tar-stream"; import gunzip from "gunzip-maybe"; -import { getBinaryName, normalizedPlatform } from "../src/common/vars"; -import { isErrnoException } from "../src/common/utils"; +import { isErrnoException, setTimeoutFor } from "../src/common/utils"; +import AbortController from "abort-controller"; + +type Response = FetchModule.Response; +type RequestInfo = FetchModule.RequestInfo; +type RequestInit = FetchModule.RequestInit; const pipeline = promisify(_pipeline); +const getBinaryName = (binaryName: string, { forPlatform }: { forPlatform : string }) => { + if (forPlatform === "windows") { + return `${binaryName}.exe`; + } + + return binaryName; +}; + interface BinaryDownloaderArgs { readonly version: string; readonly platform: SupportedPlatform; @@ -29,6 +41,10 @@ interface BinaryDownloaderArgs { readonly baseDir: string; } +interface BinaryDownloaderDependencies { + fetch: (url: RequestInfo, init?: RequestInit) => Promise; +} + abstract class BinaryDownloader { protected abstract readonly url: string; protected readonly bar: SingleBar; @@ -38,7 +54,7 @@ abstract class BinaryDownloader { return [file]; } - constructor(public readonly args: BinaryDownloaderArgs, multiBar: MultiBar) { + constructor(protected readonly dependencies: BinaryDownloaderDependencies, public readonly args: BinaryDownloaderArgs, multiBar: MultiBar) { this.bar = multiBar.create(1, 0, args); this.target = path.join(args.baseDir, args.platform, args.fileArch, args.binaryName); } @@ -49,8 +65,10 @@ abstract class BinaryDownloader { } const controller = new AbortController(); - const stream = await fetch(this.url, { - timeout: 15 * 60 * 1000, // 15min + + setTimeoutFor(controller, 15 * 60 * 1000); + + const stream = await this.dependencies.fetch(this.url, { signal: controller.signal, }); const total = Number(stream.headers.get("content-length")); @@ -72,6 +90,10 @@ abstract class BinaryDownloader { */ const handle = fileHandle = await open(this.target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL); + if (!stream.body) { + throw new Error("no body on stream"); + } + await pipeline( stream.body, new Transform({ @@ -108,10 +130,10 @@ abstract class BinaryDownloader { class LensK8sProxyDownloader extends BinaryDownloader { protected readonly url: string; - constructor(args: Omit, bar: MultiBar) { + constructor(deps: BinaryDownloaderDependencies, args: Omit, bar: MultiBar) { const binaryName = getBinaryName("lens-k8s-proxy", { forPlatform: args.platform }); - super({ ...args, binaryName }, bar); + super(deps, { ...args, binaryName }, bar); this.url = `https://github.com/lensapp/lens-k8s-proxy/releases/download/v${args.version}/lens-k8s-proxy-${args.platform}-${args.downloadArch}`; } } @@ -119,10 +141,10 @@ class LensK8sProxyDownloader extends BinaryDownloader { class KubectlDownloader extends BinaryDownloader { protected readonly url: string; - constructor(args: Omit, bar: MultiBar) { + constructor(deps: BinaryDownloaderDependencies, args: Omit, bar: MultiBar) { const binaryName = getBinaryName("kubectl", { forPlatform: args.platform }); - super({ ...args, binaryName }, bar); + super(deps, { ...args, binaryName }, bar); this.url = `https://storage.googleapis.com/kubernetes-release/release/v${args.version}/bin/${args.platform}/${args.downloadArch}/${binaryName}`; } } @@ -130,10 +152,10 @@ class KubectlDownloader extends BinaryDownloader { class HelmDownloader extends BinaryDownloader { protected readonly url: string; - constructor(args: Omit, bar: MultiBar) { + constructor(deps: BinaryDownloaderDependencies, args: Omit, bar: MultiBar) { const binaryName = getBinaryName("helm", { forPlatform: args.platform }); - super({ ...args, binaryName }, bar); + super(deps, { ...args, binaryName }, bar); this.url = `https://get.helm.sh/helm-v${args.version}-${args.platform}-${args.downloadArch}.tar.gz`; } @@ -160,7 +182,24 @@ class HelmDownloader extends BinaryDownloader { type SupportedPlatform = "darwin" | "linux" | "windows"; +const importFetchModule = new Function('return import("node-fetch")') as () => Promise; + async function main() { + const deps: BinaryDownloaderDependencies = { + fetch: (await importFetchModule()).default, + }; + const normalizedPlatform = (() => { + switch (process.platform) { + case "darwin": + return "darwin"; + case "linux": + return "linux"; + case "win32": + return "windows"; + default: + throw new Error(`platform=${process.platform} is unsupported`); + } + })(); const multiBar = new MultiBar({ align: "left", clearOnComplete: false, @@ -171,21 +210,21 @@ async function main() { }); const baseDir = path.join(__dirname, "..", "binaries", "client"); const downloaders: BinaryDownloader[] = [ - new LensK8sProxyDownloader({ + new LensK8sProxyDownloader(deps, { version: packageInfo.config.k8sProxyVersion, platform: normalizedPlatform, downloadArch: "amd64", fileArch: "x64", baseDir, }, multiBar), - new KubectlDownloader({ + new KubectlDownloader(deps, { version: packageInfo.config.bundledKubectlVersion, platform: normalizedPlatform, downloadArch: "amd64", fileArch: "x64", baseDir, }, multiBar), - new HelmDownloader({ + new HelmDownloader(deps, { version: packageInfo.config.bundledHelmVersion, platform: normalizedPlatform, downloadArch: "amd64", @@ -194,23 +233,23 @@ async function main() { }, multiBar), ]; - if (normalizedPlatform === "darwin") { + if (normalizedPlatform !== "windows") { downloaders.push( - new LensK8sProxyDownloader({ + new LensK8sProxyDownloader(deps, { version: packageInfo.config.k8sProxyVersion, platform: normalizedPlatform, downloadArch: "arm64", fileArch: "arm64", baseDir, }, multiBar), - new KubectlDownloader({ + new KubectlDownloader(deps, { version: packageInfo.config.bundledKubectlVersion, platform: normalizedPlatform, downloadArch: "arm64", fileArch: "arm64", baseDir, }, multiBar), - new HelmDownloader({ + new HelmDownloader(deps, { version: packageInfo.config.bundledHelmVersion, platform: normalizedPlatform, downloadArch: "arm64", diff --git a/build/installer.nsh b/build/installer.nsh index 8ff35b5e4f..d0fe18809a 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -1,4 +1,7 @@ !macro customInit + ; Make sure all old extensions are removed + RMDir /r "$INSTDIR\resources\extensions" + ; Workaround for installer handing when the app directory is removed manually ${ifNot} ${FileExists} "$INSTDIR" DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\{${UNINSTALL_APP_KEY}}" diff --git a/build/notarize.js b/build/notarize.js index ded81f6dd1..0bf1903e59 100644 --- a/build/notarize.js +++ b/build/notarize.js @@ -22,5 +22,6 @@ exports.default = async function notarizing(context) { appPath: `${appOutDir}/${appName}.app`, appleId: process.env.APPLEID, appleIdPassword: process.env.APPLEIDPASS, + ascProvider:process.env.ASCPROVIDER, }); }; diff --git a/build/set_build_version.ts b/build/set_build_version.ts deleted file mode 100644 index 4df7ad4b96..0000000000 --- a/build/set_build_version.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import * as fse from "fs-extra"; -import * as path from "path"; -import appInfo from "../package.json"; -import semver from "semver"; -import fastGlob from "fast-glob"; - -const packagePath = path.join(__dirname, "../package.json"); -const versionInfo = semver.parse(appInfo.version); -const buildNumber = process.env.BUILD_NUMBER || Date.now().toString(); - -function getBuildChannel(): string { - const preRelease = versionInfo.prerelease?.[0]; - - switch (preRelease) { - case "alpha": - case "beta": - case "rc": - return preRelease; - case undefined: - case "latest": - return "latest"; // needed because electron-updater does not take build information into account when resolving if update is available - default: - throw new Error(`invalid pre-release ${preRelease}`); - } -} - -async function writeOutExtensionVersion(manifestPath: string) { - const extensionPackageJson = await fse.readJson(manifestPath); - - extensionPackageJson.version = appInfo.version; - - return fse.writeJson(manifestPath, extensionPackageJson, { - spaces: 2, - }); -} - -async function writeOutNewVersions() { - await Promise.all([ - fse.writeJson(packagePath, appInfo, { - spaces: 2, - }), - ...(await fastGlob(["extensions/*/package.json"])).map(writeOutExtensionVersion), - ]); -} - -function main() { - const prereleaseParts: string[] = [getBuildChannel()]; - - if (versionInfo.prerelease && versionInfo.prerelease.length > 1) { - prereleaseParts.push(versionInfo.prerelease[1].toString()); - } - - prereleaseParts.push(buildNumber); - - appInfo.version = `${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}-${prereleaseParts.join(".")}`; - - writeOutNewVersions() - .catch((error) => { - console.error(error); - process.exit(1); - }); -} - -main(); diff --git a/docs/extensions/get-started/your-first-extension.md b/docs/extensions/get-started/your-first-extension.md index c8c1167943..ec797f7441 100644 --- a/docs/extensions/get-started/your-first-extension.md +++ b/docs/extensions/get-started/your-first-extension.md @@ -78,7 +78,7 @@ npm run dev You must restart Lens for the extension to load. After this initial restart, reload Lens and it will automatically pick up changes any time the extension rebuilds. -With Lens running, either connect to an existing cluster or create a new one - refer to the latest [Lens Documentation](https://docs.k8slens.dev/main/catalog/) for details on how to add a cluster in Lens IDE. +With Lens running, either connect to an existing cluster or create a new one - refer to the latest [Lens Documentation](https://docs.k8slens.dev/getting-started/add-cluster/) for details on how to add a cluster in Lens IDE. You will see the "Hello World" page in the left-side cluster menu. ## Develop the Extension diff --git a/docs/extensions/guides/generator.md b/docs/extensions/guides/generator.md index 64838c1bc4..fbc1723b0b 100644 --- a/docs/extensions/guides/generator.md +++ b/docs/extensions/guides/generator.md @@ -46,14 +46,14 @@ Open `my-first-lens-ext/renderer.tsx` and change the value of `title` from `"Hel ```typescript clusterPageMenus = [ - { - target: { pageId: "hello" }, - title: "Hello Lens", - components: { - Icon: ExampleIcon, - } - } -] + { + target: { pageId: "hello" }, + title: "Hello Lens", + components: { + Icon: ExampleIcon, + }, + }, +]; ``` Reload Lens and you will see that the menu item text has changed to "Hello Lens". @@ -70,6 +70,6 @@ To debug your extension, please see our instructions on [Testing Extensions](../ To dive deeper, consider looking at [Common Capabilities](../capabilities/common-capabilities.md), [Styling](../capabilities/styling.md), or [Extension Anatomy](anatomy.md). If you find problems with the Lens Extension Generator, or have feature requests, you are welcome to raise an [issue](https://github.com/lensapp/generator-lens-ext/issues). -You can find the latest Lens contribution guidelines [here](https://docs.k8slens.dev/latest/contributing). +You can find the latest Lens contribution guidelines [here](https://docs.k8slens.dev/contributing). The Generator source code is hosted at [GitHub](https://github.com/lensapp/generator-lens-ext). diff --git a/docs/extensions/guides/renderer-extension.md b/docs/extensions/guides/renderer-extension.md index d90a343692..4cc6e338bf 100644 --- a/docs/extensions/guides/renderer-extension.md +++ b/docs/extensions/guides/renderer-extension.md @@ -771,7 +771,7 @@ Construct the table using the `Renderer.Component.Table` and related elements. For each pod the name, age, and status are obtained using the `Renderer.K8sApi.Pod` methods. The table is constructed using the `Renderer.Component.Table` and related elements. -See [Component documentation](https://docs.k8slens.dev/latest/extensions/api/modules/_renderer_api_components_/) for further details. +See [Component documentation](https://api-docs.k8slens.dev/latest/extensions/api/modules/Renderer.Component/) for further details. ### `kubeObjectStatusTexts` diff --git a/extensions/kube-object-event-status/package.json b/extensions/kube-object-event-status/package.json index 53db719a83..934ebbbdb8 100644 --- a/extensions/kube-object-event-status/package.json +++ b/extensions/kube-object-event-status/package.json @@ -1,6 +1,6 @@ { "name": "kube-object-event-status", - "version": "0.0.1", + "version": "6.1.1", "description": "Adds kube object status from events", "renderer": "dist/renderer.js", "lens": { diff --git a/extensions/kube-object-event-status/renderer.tsx b/extensions/kube-object-event-status/renderer.tsx index 88f6156605..65b92a2658 100644 --- a/extensions/kube-object-event-status/renderer.tsx +++ b/extensions/kube-object-event-status/renderer.tsx @@ -35,12 +35,18 @@ export default class EventResourceStatusRendererExtension extends Renderer.LensE }, { kind: "Job", - apiVersions: ["batch/v1"], + apiVersions: [ + "batch/v1", + "batch/v1beta1", + ], resolve: (job: Renderer.K8sApi.Job) => resolveStatus(job), }, { kind: "CronJob", - apiVersions: ["batch/v1"], + apiVersions: [ + "batch/v1", + "batch/v1beta1", + ], resolve: (cronJob: Renderer.K8sApi.CronJob) => resolveStatusForCronJobs(cronJob), }, ]; diff --git a/extensions/metrics-cluster-feature/package.json b/extensions/metrics-cluster-feature/package.json index 41da8f3f86..03ad0df7a5 100644 --- a/extensions/metrics-cluster-feature/package.json +++ b/extensions/metrics-cluster-feature/package.json @@ -1,6 +1,6 @@ { "name": "lens-metrics-cluster-feature", - "version": "0.0.1", + "version": "6.1.0", "description": "Lens metrics cluster feature", "renderer": "dist/renderer.js", "lens": { diff --git a/extensions/metrics-cluster-feature/src/metrics-settings.tsx b/extensions/metrics-cluster-feature/src/metrics-settings.tsx index bfbeb3e10b..4ae4235780 100644 --- a/extensions/metrics-cluster-feature/src/metrics-settings.tsx +++ b/extensions/metrics-cluster-feature/src/metrics-settings.tsx @@ -190,7 +190,7 @@ export class MetricsSettings extends React.Component { render() { return ( - <> +
{ this.props.cluster.status.phase !== "connected" && (

@@ -270,7 +270,7 @@ export class MetricsSettings extends React.Component { )}

- +
); } } diff --git a/extensions/node-menu/package.json b/extensions/node-menu/package.json index 7705b4f606..3b56dc6e62 100644 --- a/extensions/node-menu/package.json +++ b/extensions/node-menu/package.json @@ -1,6 +1,6 @@ { "name": "lens-node-menu", - "version": "0.0.1", + "version": "6.1.0", "description": "Lens node menu", "renderer": "dist/renderer.js", "lens": { diff --git a/extensions/pod-menu/package.json b/extensions/pod-menu/package.json index 79e4278b32..ba3f107f80 100644 --- a/extensions/pod-menu/package.json +++ b/extensions/pod-menu/package.json @@ -1,6 +1,6 @@ { "name": "lens-pod-menu", - "version": "0.0.1", + "version": "6.1.0", "description": "Lens pod menu", "renderer": "dist/renderer.js", "lens": { diff --git a/integration/__tests__/app-preferences.tests.ts b/integration/__tests__/app-preferences.tests.ts index 2054a5342d..1d7d7029fb 100644 --- a/integration/__tests__/app-preferences.tests.ts +++ b/integration/__tests__/app-preferences.tests.ts @@ -13,7 +13,8 @@ import type { ElectronApplication, Page } from "playwright"; import * as utils from "../helpers/utils"; describe("preferences page tests", () => { - let window: Page, cleanup: () => Promise; + let window: Page; + let cleanup: undefined | (() => Promise); beforeEach(async () => { let app: ElectronApplication; @@ -23,20 +24,21 @@ describe("preferences page tests", () => { await app.evaluate(async ({ app }) => { await app.applicationMenu - .getMenuItemById(process.platform === "darwin" ? "root" : "file") - .submenu.getMenuItemById("preferences") - .click(); + ?.getMenuItemById(process.platform === "darwin" ? "mac" : "file") + ?.submenu + ?.getMenuItemById("navigate-to-preferences") + ?.click(); }); }, 10*60*1000); afterEach(async () => { - await cleanup(); + await cleanup?.(); }, 10*60*1000); it('shows "preferences" and can navigate through the tabs', async () => { const pages = [ { - id: "application", + id: "app", header: "Application", }, { @@ -50,8 +52,8 @@ describe("preferences page tests", () => { ]; for (const { id, header } of pages) { - await window.click(`[data-testid=tab-link-for-${id}]`); - await window.waitForSelector(`[data-testid=${id}-header] >> text=${header}`); + await window.click(`[data-preference-tab-link-test=${id}]`); + await window.waitForSelector(`[data-preference-page-title-test] >> text=${header}`); } }, 10*60*1000); diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index 15e72f0d80..842a38da88 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -14,11 +14,14 @@ import { minikubeReady } from "../helpers/minikube"; import type { Frame, Page } from "playwright"; import { groupBy, toPairs } from "lodash/fp"; import { pipeline } from "@ogre-tools/fp"; +import { describeIf } from "../../src/test-utils/skippers"; const TEST_NAMESPACE = "integration-tests"; -utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => { - let window: Page, cleanup: () => Promise, frame: Frame; +describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => { + let window: Page; + let cleanup: undefined | (() => Promise); + let frame: Frame; beforeEach(async () => { ({ window, cleanup } = await utils.start()); @@ -28,7 +31,7 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => { }, 10 * 60 * 1000); afterEach(async () => { - await cleanup(); + await cleanup?.(); }, 10 * 60 * 1000); it("shows cluster context menu in sidebar", async () => { @@ -388,12 +391,6 @@ const scenarios = [ sidebarItemTestId: "sidebar-item-link-for-service-accounts", }, - { - expectedSelector: "h5.title", - parentSidebarItemTestId: "sidebar-item-link-for-user-management", - sidebarItemTestId: "sidebar-item-link-for-roles", - }, - { expectedSelector: "h5.title", parentSidebarItemTestId: "sidebar-item-link-for-user-management", @@ -403,7 +400,7 @@ const scenarios = [ { expectedSelector: "h5.title", parentSidebarItemTestId: "sidebar-item-link-for-user-management", - sidebarItemTestId: "sidebar-item-link-for-role-bindings", + sidebarItemTestId: "sidebar-item-link-for-roles", }, { @@ -415,7 +412,7 @@ const scenarios = [ { expectedSelector: "h5.title", parentSidebarItemTestId: "sidebar-item-link-for-user-management", - sidebarItemTestId: "sidebar-item-link-for-pod-security-policies", + sidebarItemTestId: "sidebar-item-link-for-role-bindings", }, { diff --git a/integration/__tests__/command-palette.tests.ts b/integration/__tests__/command-palette.tests.ts index 8356379b60..670a64c679 100644 --- a/integration/__tests__/command-palette.tests.ts +++ b/integration/__tests__/command-palette.tests.ts @@ -7,7 +7,9 @@ import type { ElectronApplication, Page } from "playwright"; import * as utils from "../helpers/utils"; describe("Lens command palette", () => { - let window: Page, cleanup: () => Promise, app: ElectronApplication; + let window: Page; + let cleanup: undefined | (() => Promise); + let app: ElectronApplication; beforeEach(async () => { ({ window, cleanup, app } = await utils.start()); @@ -15,7 +17,7 @@ describe("Lens command palette", () => { }, 10*60*1000); afterEach(async () => { - await cleanup(); + await cleanup?.(); }, 10*60*1000); describe("menu", () => { @@ -23,7 +25,7 @@ describe("Lens command palette", () => { await app.evaluate(async ({ app }) => { await app.applicationMenu ?.getMenuItemById("view") - ?.submenu?.getMenuItemById("command-palette") + ?.submenu?.getMenuItemById("open-command-palette") ?.click(); }); await window.waitForSelector(".Select__option >> text=Hotbar: Switch"); diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index f8eca46c26..02aef4f3fd 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -10,6 +10,7 @@ import * as uuid from "uuid"; import type { ElectronApplication, Frame, Page } from "playwright"; import { _electron as electron } from "playwright"; import { noop } from "lodash"; +import { disposer } from "../../src/common/utils"; export const appPaths: Partial> = { "win32": "./dist/win-unpacked/OpenLens.exe", @@ -17,28 +18,47 @@ export const appPaths: Partial> = { "darwin": "./dist/mac/OpenLens.app/Contents/MacOS/OpenLens", }; -export function itIf(condition: boolean) { - return condition ? it : it.skip; -} - -export function describeIf(condition: boolean) { - return condition ? describe : describe.skip; -} - async function getMainWindow(app: ElectronApplication, timeout = 50_000): Promise { - const deadline = Date.now() + timeout; + return new Promise((resolve, reject) => { + const cleanup = disposer(); + let stdoutBuf = ""; + + const onWindow = (page: Page) => { + console.log(`Page opened: ${page.url()}`); - for (; Date.now() < deadline;) { - for (const page of app.windows()) { if (page.url().startsWith("http://localhost")) { - return page; + cleanup(); + console.log(stdoutBuf); + resolve(page); } - } + }; - await new Promise(resolve => setTimeout(resolve, 2_000)); - } + app.on("window", onWindow); + cleanup.push(() => app.off("window", onWindow)); - throw new Error(`Lens did not open the main window within ${timeout}ms`); + const onClose = () => { + cleanup(); + reject(new Error("App has unnexpectedly closed")); + }; + + app.on("close", onClose); + cleanup.push(() => app.off("close", onClose)); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const stdout = app.process().stdout!; + const onData = (chunk: any) => stdoutBuf += chunk.toString(); + + stdout.on("data", onData); + cleanup.push(() => stdout.off("data", onData)); + + const timeoutId = setTimeout(() => { + cleanup(); + console.log(stdoutBuf); + reject(new Error(`Lens did not open the main window within ${timeout}ms`)); + }, timeout); + + cleanup.push(() => clearTimeout(timeoutId)); + }); } async function attemptStart() { @@ -57,7 +77,7 @@ async function attemptStart() { ...process.env, }, timeout: 100_000, - } as Parameters[0]); + }); try { const window = await getMainWindow(app); diff --git a/jsonnet/lens/custom-prometheus.jsonnet b/jsonnet/lens/custom-prometheus.jsonnet index 9c733766ba..10384fdaa5 100644 --- a/jsonnet/lens/custom-prometheus.jsonnet +++ b/jsonnet/lens/custom-prometheus.jsonnet @@ -24,7 +24,7 @@ }, }, }, - prometheus+:: { + kubernetesControlPlane+:: { serviceMonitorKubelet+: { spec+: { endpoints: std.map(function(endpoint) diff --git a/package.json b/package.json index 0613e54e2c..ff1aed0141 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "productName": "OpenLens", "description": "OpenLens - Open Source IDE for Kubernetes", "homepage": "https://github.com/lensapp/lens", - "version": "6.0.0", + "version": "6.3.0-alpha.0", "main": "static/build/main.js", "copyright": "© 2022 OpenLens Authors", "license": "MIT", @@ -26,7 +26,8 @@ "compile:main": "yarn run webpack --config webpack/main.ts", "compile:renderer": "yarn run webpack --config webpack/renderer.ts", "compile:extension-types": "yarn run webpack --config webpack/extensions.ts", - "npm:fix-build-version": "yarn run ts-node build/set_build_version.ts", + "compile:node-fetch": "yarn run webpack --config ./webpack/node-fetch.ts", + "postinstall": "yarn run compile:node-fetch", "npm:fix-package-version": "yarn run ts-node build/set_npm_version.ts", "build:linux": "yarn run compile && electron-builder --linux --dir", "build:mac": "yarn run compile && electron-builder --mac --dir", @@ -52,11 +53,12 @@ "create-release-pr": "node ./scripts/create-release-pr.mjs" }, "config": { - "k8sProxyVersion": "0.2.1", + "k8sProxyVersion": "0.3.0", "bundledKubectlVersion": "1.23.3", "bundledHelmVersion": "3.7.2", "sentryDsn": "", - "contentSecurityPolicy": "script-src 'unsafe-eval' 'self'; frame-src http://*.localhost:*/; img-src * data:" + "contentSecurityPolicy": "script-src 'unsafe-eval' 'self'; frame-src http://*.localhost:*/; img-src * data:", + "welcomeRoute": "/welcome" }, "engines": { "node": ">=16 <17" @@ -216,14 +218,14 @@ "@astronautlabs/jsonpath": "^1.1.0", "@hapi/call": "^9.0.0", "@hapi/subtext": "^7.0.4", - "@kubernetes/client-node": "^0.17.0", + "@kubernetes/client-node": "^0.17.1", "@material-ui/styles": "^4.11.5", - "@ogre-tools/fp": "10.1.0", - "@ogre-tools/injectable": "10.1.0", - "@ogre-tools/injectable-extension-for-auto-registration": "10.1.0", - "@ogre-tools/injectable-extension-for-mobx": "10.1.0", - "@ogre-tools/injectable-react": "10.1.0", - "@sentry/electron": "^3.0.7", + "@ogre-tools/fp": "^12.0.1", + "@ogre-tools/injectable": "^12.0.1", + "@ogre-tools/injectable-extension-for-auto-registration": "^12.0.1", + "@ogre-tools/injectable-extension-for-mobx": "^12.0.1", + "@ogre-tools/injectable-react": "^12.0.1", + "@sentry/electron": "^3.0.8", "@sentry/integrations": "^6.19.3", "@side/jest-runtime": "^1.0.1", "@tanstack/react-virtual": "3.0.0-beta.18", @@ -246,43 +248,40 @@ "handlebars": "^4.7.7", "history": "^4.10.1", "http-proxy": "^1.18.1", - "immer": "^9.0.15", - "joi": "^17.6.0", + "immer": "^9.0.16", + "joi": "^17.7.0", "js-yaml": "^4.1.0", "jsdom": "^16.7.0", "lodash": "^4.17.15", - "mac-ca": "^1.0.6", - "marked": "^4.1.0", + "marked": "^4.2.3", "md5-file": "^5.0.0", - "mobx": "^6.6.2", + "mobx": "^6.7.0", "mobx-observable-history": "^2.0.3", - "mobx-react": "^7.5.3", + "mobx-react": "^7.6.0", "mobx-utils": "^6.0.4", - "mock-fs": "^5.1.4", + "mock-fs": "^5.2.0", "moment": "^2.29.4", - "moment-timezone": "^0.5.37", + "moment-timezone": "^0.5.39", "monaco-editor": "^0.29.1", "monaco-editor-webpack-plugin": "^5.0.0", - "node-fetch": "^2.6.7", + "node-fetch": "^3.3.0", "node-pty": "0.10.1", - "npm": "^8.19.1", + "npm": "^8.19.3", "p-limit": "^3.1.0", "path-to-regexp": "^6.2.0", "proper-lockfile": "^4.1.2", "react": "^17.0.2", "react-dom": "^17.0.2", "react-material-ui-carousel": "^2.3.11", - "react-router": "^5.2.0", - "react-virtualized-auto-sizer": "^1.0.6", + "react-router": "^5.3.4", + "react-virtualized-auto-sizer": "^1.0.7", "readable-stream": "^3.6.0", "request": "^2.88.2", "request-promise-native": "^1.0.9", "rfc6902": "^4.0.2", "selfsigned": "^2.1.1", - "semver": "^7.3.7", - "shell-env": "^3.0.1", - "spdy": "^4.0.2", - "tar": "^6.1.11", + "semver": "^7.3.8", + "tar": "^6.1.12", "tcp-port-used": "^1.0.2", "tempy": "1.0.1", "typed-regex": "^0.0.8", @@ -290,9 +289,8 @@ "uuid": "^8.3.2", "win-ca": "^3.5.0", "winston": "^3.8.2", - "winston-console-format": "^1.0.8", "winston-transport-browserconsole": "^1.0.5", - "ws": "^8.8.1", + "ws": "^8.11.0", "xterm-link-provider": "^1.3.1" }, "devDependencies": { @@ -300,11 +298,11 @@ "@material-ui/core": "^4.12.3", "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.60", - "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", "@sentry/types": "^6.19.7", "@swc/cli": "^0.1.57", - "@swc/core": "^1.2.249", - "@swc/jest": "^0.2.22", + "@swc/core": "^1.3.19", + "@swc/jest": "^0.2.23", "@testing-library/dom": "^7.31.2", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.5", @@ -316,7 +314,7 @@ "@types/color": "^3.0.3", "@types/command-line-args": "^5.2.0", "@types/crypto-js": "^3.1.47", - "@types/dompurify": "^2.3.4", + "@types/dompurify": "^2.4.0", "@types/electron-devtools-installer": "^2.2.1", "@types/fs-extra": "^9.0.13", "@types/glob-to-regexp": "^0.4.1", @@ -328,20 +326,19 @@ "@types/jest": "^28.1.6", "@types/js-yaml": "^4.0.5", "@types/jsdom": "^16.2.14", - "@types/lodash": "^4.14.184", + "@types/lodash": "^4.14.190", "@types/marked": "^4.0.7", "@types/md5-file": "^4.0.2", + "@types/memorystream": "^0.3.0", "@types/mini-css-extract-plugin": "^2.4.0", "@types/mock-fs": "^4.13.1", - "@types/node": "^16.11.58", - "@types/node-fetch": "^2.6.2", - "@types/npm": "^2.0.32", + "@types/node": "^16.18.2", "@types/proper-lockfile": "^4.1.2", "@types/randomcolor": "^0.5.6", "@types/react": "^17.0.45", "@types/react-beautiful-dnd": "^13.1.2", "@types/react-dom": "^17.0.16", - "@types/react-router": "^5.1.18", + "@types/react-router": "^5.1.19", "@types/react-router-dom": "^5.3.3", "@types/react-table": "^7.7.12", "@types/react-virtualized-auto-sizer": "^1.0.1", @@ -349,10 +346,9 @@ "@types/readable-stream": "^2.3.13", "@types/request": "^2.48.7", "@types/request-promise-native": "^1.0.18", - "@types/semver": "^7.3.12", - "@types/sharp": "^0.30.5", - "@types/spdy": "^3.4.5", - "@types/tar": "^4.0.5", + "@types/semver": "^7.3.13", + "@types/sharp": "^0.31.0", + "@types/tar": "^6.1.3", "@types/tar-stream": "^2.2.2", "@types/tcp-port-used": "^1.0.1", "@types/tempy": "^0.3.0", @@ -363,31 +359,32 @@ "@types/webpack-dev-server": "^4.7.2", "@types/webpack-env": "^1.18.0", "@types/webpack-node-externals": "^2.5.3", - "@typescript-eslint/eslint-plugin": "^5.36.2", - "@typescript-eslint/parser": "^5.36.2", - "adr": "^1.4.1", + "@typescript-eslint/eslint-plugin": "^5.44.0", + "@typescript-eslint/parser": "^5.44.0", + "adr": "^1.4.3", "ansi_up": "^5.1.0", + "chalk": "^4.1.2", "chart.js": "^2.9.4", "circular-dependency-plugin": "^5.2.2", "cli-progress": "^3.11.2", "color": "^3.2.1", "command-line-args": "^5.2.1", - "concurrently": "^7.4.0", - "css-loader": "^6.7.1", + "concurrently": "^7.6.0", + "css-loader": "^6.7.2", "deepdash": "^5.3.9", - "dompurify": "^2.4.0", - "electron": "^19.0.16", - "electron-builder": "^23.3.3", + "dompurify": "^2.4.1", + "electron": "^19.1.7", + "electron-builder": "^23.6.0", "electron-notarize": "^0.3.0", - "esbuild": "^0.15.7", + "esbuild": "^0.15.15", "esbuild-loader": "^2.20.0", - "eslint": "^8.23.0", + "eslint": "^8.28.0", "eslint-plugin-header": "^3.1.1", "eslint-plugin-import": "^2.26.0", - "eslint-plugin-react": "7.31.7", + "eslint-plugin-react": "7.31.11", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-unused-imports": "^2.0.0", - "flex.box": "^3.4.4", + "eslint-import-resolver-typescript": "^3.5.2", "fork-ts-checker-webpack-plugin": "^6.5.2", "gunzip-maybe": "^1.4.2", "html-webpack-plugin": "^5.5.0", @@ -397,44 +394,44 @@ "jest": "^28.1.3", "jest-canvas-mock": "^2.3.1", "jest-environment-jsdom": "^28.1.3", - "jest-fetch-mock": "^3.0.3", - "jest-mock-extended": "^2.0.7", + "jest-mock-extended": "^2.0.9", "make-plural": "^6.2.2", - "mini-css-extract-plugin": "^2.6.1", + "memorystream": "^0.3.1", + "mini-css-extract-plugin": "^2.7.0", "mock-http": "^1.1.0", "node-gyp": "^8.3.0", "node-loader": "^2.0.0", - "nodemon": "^2.0.19", - "playwright": "^1.25.2", - "postcss": "^8.4.16", + "nodemon": "^2.0.20", + "playwright": "^1.28.1", + "postcss": "^8.4.19", "postcss-loader": "^6.2.1", "query-string": "^7.1.1", "randomcolor": "^0.6.2", "react-beautiful-dnd": "^13.1.1", "react-refresh": "^0.14.0", "react-refresh-typescript": "^2.0.7", - "react-router-dom": "^5.3.3", - "react-select": "^5.4.0", + "react-router-dom": "^5.3.4", + "react-select": "^5.6.1", "react-select-event": "^5.5.1", "react-table": "^7.8.0", - "react-window": "^1.8.7", - "sass": "^1.54.9", + "react-window": "^1.8.8", + "sass": "^1.56.1", "sass-loader": "^12.6.0", - "sharp": "^0.31.0", + "sharp": "^0.31.2", "style-loader": "^3.3.1", - "tailwindcss": "^3.1.8", + "tailwindcss": "^3.2.4", "tar-stream": "^2.2.0", - "ts-loader": "^9.3.1", + "ts-loader": "^9.4.1", "ts-node": "^10.9.1", "type-fest": "^2.14.0", "typed-emitter": "^1.4.0", - "typedoc": "0.23.14", - "typedoc-plugin-markdown": "^3.13.1", - "typescript": "^4.8.2", + "typedoc": "0.23.21", + "typedoc-plugin-markdown": "^3.13.6", + "typescript": "^4.9.3", "typescript-plugin-css-modules": "^3.4.0", - "webpack": "^5.74.0", + "webpack": "^5.75.0", "webpack-cli": "^4.9.2", - "webpack-dev-server": "^4.11.0", + "webpack-dev-server": "^4.11.1", "webpack-node-externals": "^3.0.0", "xterm": "^4.19.0", "xterm-addon-fit": "^0.5.0" diff --git a/scripts/create-release-pr.ts b/scripts/create-release-pr.ts index da0d5d4f26..202f340969 100755 --- a/scripts/create-release-pr.ts +++ b/scripts/create-release-pr.ts @@ -8,7 +8,7 @@ import fse from "fs-extra"; import { basename } from "path"; import { createInterface } from "readline"; import semver from "semver"; -import { inspect, promisify } from "util"; +import { promisify } from "util"; const { SemVer, @@ -27,6 +27,10 @@ const options = commandLineArgs([ { name: "preid", }, + { + name: "check-commits", + type: Boolean, + }, ]); const validReleaseValues = [ @@ -79,10 +83,22 @@ if (basename(process.cwd()) === "scripts") { console.error(errorMessages.wrongCwd); } - -const currentVersion = new SemVer((await fse.readJson("./package.json")).version); +const packageJson = await fse.readJson("./package.json"); +const currentVersion = new SemVer(packageJson.version); console.log(`current version: ${currentVersion.format()}`); + +const newVersion = currentVersion.inc(options.type, options.preid); +const newVersionMilestone = `${newVersion.major}.${newVersion.minor}.${newVersion.patch}`; +const prBranch = `release/v${newVersion.format()}`; + +await fse.writeJson("./package.json", { ...packageJson, version: newVersion.format() }, { spaces: 2 }); +await exec(`git checkout -b ${prBranch}`); +await exec("git add package.json"); +await exec(`git commit -sm "Release ${newVersion.format()}"`); + +console.log(`new version: ${newVersion.format()}`); + console.log("fetching tags..."); await exec("git fetch --tags --force"); @@ -93,25 +109,6 @@ const [previousReleasedVersion] = actualTags .sort((l, r) => semverRcompare(l, r)) .filter(version => semverLte(version, currentVersion)); -const npmVersionArgs = [ - "npm", - "version", - options.type, -]; - -if (options.preid) { - npmVersionArgs.push(`--preid=${options.preid}`); -} - -npmVersionArgs.push("--git-tag-version false"); - -await exec(npmVersionArgs.join(" ")); - -const newVersion = new SemVer((await fse.readJson("./package.json")).version); -const newVersionMilestone = `${newVersion.major}.${newVersion.minor}.${newVersion.patch}`; - -console.log(`new version: ${newVersion.format()}`); - const getMergedPrsArgs = [ "gh", "pr", @@ -146,6 +143,10 @@ interface GithubPrData { title: string; } +interface ExtendedGithubPrData extends Omit { + mergedAt: Date; +} + console.log("retreiving last 500 PRs to create release PR body..."); const mergedPrs = JSON.parse((await exec(getMergedPrsArgs.join(" "), { encoding: "utf-8" })).stdout) as GithubPrData[]; const milestoneRelevantPrs = mergedPrs.filter(pr => pr.milestone?.title === newVersionMilestone); @@ -159,7 +160,7 @@ const relaventPrs = relaventPrsQuery .filter(query => query.stdout) .map(query => query.pr) .filter(pr => pr.labels.every(label => label.name !== "skip-changelog")) - .map(pr => ({ ...pr, mergedAt: new Date(pr.mergedAt) })) + .map(pr => ({ ...pr, mergedAt: new Date(pr.mergedAt) } as ExtendedGithubPrData)) .sort((left, right) => { const leftAge = left.mergedAt.valueOf(); const rightAge = right.mergedAt.valueOf(); @@ -175,75 +176,55 @@ const relaventPrs = relaventPrsQuery return -1; }); -console.log(inspect(relaventPrs, false, null, true)); - const enhancementPrLabelName = "enhancement"; const bugfixPrLabelName = "bug"; -const enhancementPrs = relaventPrs.filter(pr => pr.labels.some(label => label.name === enhancementPrLabelName)); -const bugfixPrs = relaventPrs.filter(pr => pr.labels.some(label => label.name === bugfixPrLabelName)); -const maintenencePrs = relaventPrs.filter(pr => pr.labels.every(label => label.name !== bugfixPrLabelName && label.name !== enhancementPrLabelName)); +const isEnhancementPr = (pr: ExtendedGithubPrData) => pr.labels.some(label => label.name === enhancementPrLabelName); +const isBugfixPr = (pr: ExtendedGithubPrData) => pr.labels.some(label => label.name === bugfixPrLabelName); -console.log("Found:"); -console.log(`${enhancementPrs.length} enhancement PRs`); -console.log(`${bugfixPrs.length} bug fix PRs`); -console.log(`${maintenencePrs.length} maintenence PRs`); +const prLines = { + enhancement: [] as string[], + bugfix: [] as string[], + maintenence: [] as string[], +}; -const prBodyLines = [ - `## Changes since ${previousReleasedVersion}`, - "", -]; - -function getPrEntry(pr) { +function getPrEntry(pr: ExtendedGithubPrData) { return `- ${pr.title} (**[#${pr.number}](https://github.com/lensapp/lens/pull/${pr.number})**) https://github.com/${pr.author.login}`; } -if (enhancementPrs.length > 0) { - prBodyLines.push( - "## 🚀 Features", - "", - ...enhancementPrs.map(getPrEntry), - "", - ); -} - -if (bugfixPrs.length > 0) { - prBodyLines.push( - "## 🐛 Bug Fixes", - "", - ...bugfixPrs.map(getPrEntry), - "", - ); -} - -if (maintenencePrs.length > 0) { - prBodyLines.push( - "## 🧰 Maintenance", - "", - ...maintenencePrs.map(getPrEntry), - "", - ); -} - -const prBody = prBodyLines.join("\n"); +const rl = createInterface(process.stdin); const prBase = newVersion.patch === 0 ? "master" : `release/v${newVersion.major}.${newVersion.minor}`; -const createPrArgs = [ - "pr", - "create", - "--base", prBase, - "--title", `release ${newVersion.format()}`, - "--label", "skip-changelog", - "--body-file", "-", -]; -const rl = createInterface(process.stdin); +function askQuestion(question: string): Promise { + return new Promise(resolve => { + function _askQuestion() { + console.log(question); -if (prBase !== "master") { - console.log("Cherry-picking commits to current branch"); + rl.once("line", (answer) => { + const cleaned = answer.trim().toLowerCase(); - for (const pr of relaventPrs) { + if (cleaned === "y") { + resolve(true); + } else if (cleaned === "n") { + resolve(false); + } else { + _askQuestion(); + } + }); + } + + _askQuestion(); + }); +} + +async function handleRelaventPr(pr: ExtendedGithubPrData) { + if (options["check-commits"] && !(await askQuestion(`Would you like to use #${pr.number}: ${pr.title}? - Y/N`))) { + return; + } + + if (prBase !== "master") { try { const promise = exec(`git cherry-pick ${pr.mergeCommit.oid}`); @@ -255,11 +236,72 @@ if (prBase !== "master") { await promise; } catch { console.error(`Failed to cherry-pick ${pr.mergeCommit.oid}, please resolve conflicts and then press enter here:`); - await new Promise(resolve => rl.on("line", () => resolve())); + await new Promise(resolve => rl.once("line", () => resolve())); } } + + if (isEnhancementPr(pr)) { + prLines.enhancement.push(getPrEntry(pr)); + } else if (isBugfixPr(pr)) { + prLines.bugfix.push(getPrEntry(pr)); + } else { + prLines.maintenence.push(getPrEntry(pr)); + } } +for (const pr of relaventPrs) { + await handleRelaventPr(pr); +} + +rl.close(); + +const prBodyLines = [ + `## Changes since ${previousReleasedVersion}`, + "", + ...( + prLines.enhancement.length > 0 + ? [ + "## 🚀 Features", + "", + ...prLines.enhancement, + "", + ] + : [] + ), + ...( + prLines.bugfix.length > 0 + ? [ + "## 🐛 Bug Fixes", + "", + ...prLines.bugfix, + "", + ] + : [] + ), + ...( + prLines.maintenence.length > 0 + ? [ + "## 🧰 Maintenance", + "", + ...prLines.maintenence, + "", + ] + : [] + ), +]; +const prBody = prBodyLines.join("\n"); +const createPrArgs = [ + "pr", + "create", + "--base", prBase, + "--title", `Release ${newVersion.format()}`, + "--label", "skip-changelog", + "--label", "release", + "--body-file", "-", +]; + +await exec(`git push --set-upstream origin ${prBranch}`); + const createPrProcess = execFile("gh", createPrArgs); createPrProcess.child.stdout?.pipe(process.stdout); diff --git a/scripts/tag-release.sh b/scripts/tag-release.sh deleted file mode 100755 index ce2ddffb2b..0000000000 --- a/scripts/tag-release.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash - -while [[ $# -gt 0 ]]; do - key="$1" - - case $key in - -f|--force) - FORCE="--force" - shift # past argument - ;; - esac -done - -if [[ `git branch --show-current` =~ ^release/v ]] -then - VERSION_STRING=$(cat package.json | jq '.version' -r | xargs printf "v%s") - git tag ${VERSION_STRING} ${FORCE} - git push ${GIT_REMOTE:-origin} ${VERSION_STRING} ${FORCE} -else - echo "You must be in a release branch" -fi diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index b74659aea0..2253c7662f 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -18,13 +18,13 @@ import { createClusterInjectionToken } from "../cluster/create-cluster-injection import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; import getConfigurationFileModelInjectable from "../get-configuration-file-model/get-configuration-file-model.injectable"; -import appVersionInjectable from "../vars/app-version.injectable"; import assert from "assert"; import directoryForTempInjectable from "../app-paths/directory-for-temp/directory-for-temp.injectable"; import kubectlBinaryNameInjectable from "../../main/kubectl/binary-name.injectable"; import kubectlDownloadingNormalizedArchInjectable from "../../main/kubectl/normalized-arch.injectable"; import normalizedPlatformInjectable from "../vars/normalized-platform.injectable"; import fsInjectable from "../fs/fs.injectable"; +import storeMigrationVersionInjectable from "../vars/store-migration-version.injectable"; console = new Console(stdout, stderr); @@ -372,7 +372,7 @@ users: mockFs(mockOpts); - mainDi.override(appVersionInjectable, () => "3.6.0"); + mainDi.override(storeMigrationVersionInjectable, () => "3.6.0"); createCluster = mainDi.inject(createClusterInjectionToken); diff --git a/src/common/__tests__/event-bus.test.ts b/src/common/__tests__/event-bus.test.ts deleted file mode 100644 index f90381eb44..0000000000 --- a/src/common/__tests__/event-bus.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { AppEvent } from "../app-event-bus/event-bus"; -import { appEventBus } from "../app-event-bus/event-bus"; -import { assert, Console } from "console"; -import { stdout, stderr } from "process"; - -console = new Console(stdout, stderr); - -describe("event bus tests", () => { - describe("emit", () => { - it("emits an event", () => { - let event: AppEvent | undefined; - - appEventBus.addListener((data) => { - event = data; - }); - - appEventBus.emit({ name: "foo", action: "bar" }); - assert(event); - expect(event?.name).toBe("foo"); - }); - }); -}); diff --git a/src/common/__tests__/hotbar-store.test.ts b/src/common/__tests__/hotbar-store.test.ts index d847ac76dc..0e1b3e27a2 100644 --- a/src/common/__tests__/hotbar-store.test.ts +++ b/src/common/__tests__/hotbar-store.test.ts @@ -8,7 +8,6 @@ import mockFs from "mock-fs"; import type { CatalogEntity, CatalogEntityData, CatalogEntityKindData } from "../catalog"; import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; import getConfigurationFileModelInjectable from "../get-configuration-file-model/get-configuration-file-model.injectable"; -import appVersionInjectable from "../vars/app-version.injectable"; import type { DiContainer } from "@ogre-tools/injectable"; import hotbarStoreInjectable from "../hotbars/store.injectable"; import type { HotbarStore } from "../hotbars/store"; @@ -19,6 +18,7 @@ import catalogCatalogEntityInjectable from "../catalog-entities/general-catalog- import loggerInjectable from "../logger.injectable"; import type { Logger } from "../logger"; import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import storeMigrationVersionInjectable from "../vars/store-migration-version.injectable"; function getMockCatalogEntity(data: Partial & CatalogEntityKindData): CatalogEntity { return { @@ -348,7 +348,7 @@ describe("HotbarStore", () => { mockFs(configurationToBeMigrated); - di.override(appVersionInjectable, () => "5.0.0-beta.10"); + di.override(storeMigrationVersionInjectable, () => "5.0.0-beta.10"); hotbarStore = di.inject(hotbarStoreInjectable); diff --git a/src/common/__tests__/system-ca.test.ts b/src/common/__tests__/system-ca.test.ts deleted file mode 100644 index 473fe6ed57..0000000000 --- a/src/common/__tests__/system-ca.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import https from "https"; -import os from "os"; -import { getMacRootCA, getWinRootCA, injectCAs, DSTRootCAX3 } from "../system-ca"; -import { dependencies, devDependencies } from "../../../package.json"; -import assert from "assert"; - -const deps = { ...dependencies, ...devDependencies }; - -// Skip the test if mac-ca is not installed, or os is not darwin -(deps["mac-ca"] && os.platform().includes("darwin") ? describe: describe.skip)("inject CA for Mac", () => { - // for reset https.globalAgent.options.ca after testing - let _ca: string | Buffer | (string | Buffer)[] | undefined; - - beforeEach(() => { - _ca = https.globalAgent.options.ca; - }); - - afterEach(() => { - https.globalAgent.options.ca = _ca; - }); - - /** - * The test to ensure using getMacRootCA + injectCAs injects CAs in the same way as using - * the auto injection (require('mac-ca')) - */ - it("should inject the same ca as mac-ca", async () => { - const osxCAs = await getMacRootCA(); - - injectCAs(osxCAs); - const injected = https.globalAgent.options.ca as (string | Buffer)[]; - - await import("mac-ca"); - const injectedByMacCA = https.globalAgent.options.ca as (string | Buffer)[]; - - expect(new Set(injected)).toEqual(new Set(injectedByMacCA)); - }); - - it("shouldn't included the expired DST Root CA X3 on Mac", async () => { - const osxCAs = await getMacRootCA(); - - injectCAs(osxCAs); - const injected = https.globalAgent.options.ca; - - assert(injected); - expect(injected.includes(DSTRootCAX3)).toBeFalsy(); - }); -}); - -// Skip the test if win-ca is not installed, or os is not win32 -(deps["win-ca"] && os.platform().includes("win32") ? describe: describe.skip)("inject CA for Windows", () => { - // for reset https.globalAgent.options.ca after testing - let _ca: string | Buffer | (string | Buffer)[] | undefined; - - beforeEach(() => { - _ca = https.globalAgent.options.ca; - }); - - afterEach(() => { - https.globalAgent.options.ca = _ca; - }); - - /** - * The test to ensure using win-ca/api injects CAs in the same way as using - * the auto injection (require('win-ca').inject('+')) - */ - it("should inject the same ca as winca.inject('+')", async () => { - const winCAs = await getWinRootCA(); - - const wincaAPI = await import("win-ca/api"); - - wincaAPI.inject("+", winCAs); - const injected = https.globalAgent.options.ca as (string | Buffer)[]; - - const winca = await import("win-ca"); - - winca.inject("+"); // see: https://github.com/ukoloff/win-ca#caveats - const injectedByWinCA = https.globalAgent.options.ca as (string | Buffer)[]; - - expect(new Set(injected)).toEqual(new Set(injectedByWinCA)); - }); - - it("shouldn't included the expired DST Root CA X3 on Windows", async () => { - const winCAs = await getWinRootCA(); - - const wincaAPI = await import("win-ca/api"); - - wincaAPI.inject("true", winCAs); - const injected = https.globalAgent.options.ca as (string | Buffer)[]; - - expect(injected.includes(DSTRootCAX3)).toBeFalsy(); - }); -}); - - - diff --git a/src/common/__tests__/user-store.test.ts b/src/common/__tests__/user-store.test.ts index 23740c5457..eeadbc1e6c 100644 --- a/src/common/__tests__/user-store.test.ts +++ b/src/common/__tests__/user-store.test.ts @@ -23,8 +23,6 @@ jest.mock("electron", () => ({ import type { UserStore } from "../user-store"; import { Console } from "console"; -import { SemVer } from "semver"; -import electron from "electron"; import { stdout, stderr } from "process"; import userStoreInjectable from "../user-store/user-store.injectable"; import type { DiContainer } from "@ogre-tools/injectable"; @@ -34,7 +32,9 @@ import { defaultThemeId } from "../vars"; import writeFileInjectable from "../fs/write-file.injectable"; import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; import getConfigurationFileModelInjectable from "../get-configuration-file-model/get-configuration-file-model.injectable"; -import appVersionInjectable from "../vars/app-version.injectable"; +import storeMigrationVersionInjectable from "../vars/store-migration-version.injectable"; +import releaseChannelInjectable from "../vars/release-channel.injectable"; +import defaultUpdateChannelInjectable from "../../features/application-update/common/selected-update-channel/default-update-channel.injectable"; console = new Console(stdout, stderr); @@ -42,7 +42,7 @@ describe("user store tests", () => { let userStore: UserStore; let di: DiContainer; - beforeEach(() => { + beforeEach(async () => { di = getDiForUnitTesting({ doGeneralOverrides: true }); mockFs(); @@ -52,6 +52,12 @@ describe("user store tests", () => { di.permitSideEffects(getConfigurationFileModelInjectable); di.permitSideEffects(userStoreInjectable); + di.override(releaseChannelInjectable, () => ({ + get: () => "latest" as const, + init: async () => {}, + })); + await di.inject(defaultUpdateChannelInjectable).init(); + di.unoverride(userStoreInjectable); }); @@ -64,6 +70,7 @@ describe("user store tests", () => { mockFs({ "some-directory-for-user-data": { "config.json": "{}", "kube_config": "{}" }}); userStore = di.inject(userStoreInjectable); + userStore.load(); }); it("allows setting and retrieving lastSeenAppVersion", () => { @@ -86,13 +93,6 @@ describe("user store tests", () => { userStore.resetTheme(); expect(userStore.colorTheme).toBe(defaultThemeId); }); - - it("correctly calculates if the last seen version is an old release", () => { - expect(userStore.isNewVersion).toBe(true); - - userStore.lastSeenAppVersion = (new SemVer(electron.app.getVersion())).inc("major").format(); - expect(userStore.isNewVersion).toBe(false); - }); }); describe("migrations", () => { @@ -125,9 +125,10 @@ describe("user store tests", () => { }, }); - di.override(appVersionInjectable, () => "10.0.0"); + di.override(storeMigrationVersionInjectable, () => "10.0.0"); userStore = di.inject(userStoreInjectable); + userStore.load(); }); it("sets last seen app version to 0.0.0", () => { diff --git a/src/common/app-event-bus/app-event-bus.injectable.ts b/src/common/app-event-bus/app-event-bus.injectable.ts index d707ec9fc3..3dee975f7b 100644 --- a/src/common/app-event-bus/app-event-bus.injectable.ts +++ b/src/common/app-event-bus/app-event-bus.injectable.ts @@ -3,12 +3,12 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { appEventBus } from "./event-bus"; +import { EventEmitter } from "../event-emitter"; +import type { AppEvent } from "./event-bus"; const appEventBusInjectable = getInjectable({ id: "app-event-bus", - instantiate: () => appEventBus, - causesSideEffects: true, + instantiate: () => new EventEmitter<[AppEvent]>, decorable: false, }); diff --git a/src/common/app-event-bus/emit-event.injectable.ts b/src/common/app-event-bus/emit-event.injectable.ts index d5aaafe37b..9c9194ceb8 100644 --- a/src/common/app-event-bus/emit-event.injectable.ts +++ b/src/common/app-event-bus/emit-event.injectable.ts @@ -4,11 +4,18 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import appEventBusInjectable from "./app-event-bus.injectable"; +import type { AppEvent } from "./event-bus"; -const emitEventInjectable = getInjectable({ - id: "emit-event", - instantiate: (di) => di.inject(appEventBusInjectable).emit, +export type EmitAppEvent = (event: AppEvent) => void; + +const emitAppEventInjectable = getInjectable({ + id: "emit-app-event", + instantiate: (di): EmitAppEvent => { + const bus = di.inject(appEventBusInjectable); + + return (event) => bus.emit(event); + }, decorable: false, }); -export default emitEventInjectable; +export default emitAppEventInjectable; diff --git a/src/common/app-event-bus/event-bus.ts b/src/common/app-event-bus/event-bus.ts index 67d23587c7..d121b842f7 100644 --- a/src/common/app-event-bus/event-bus.ts +++ b/src/common/app-event-bus/event-bus.ts @@ -3,13 +3,12 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { EventEmitter } from "../event-emitter"; - +/** + * Data for telemetry + */ export interface AppEvent { name: string; action: string; destination?: string; params?: Record; } - -export const appEventBus = new EventEmitter<[AppEvent]>(); diff --git a/src/common/app-paths/app-paths-channel.injectable.ts b/src/common/app-paths/app-paths-channel.injectable.ts deleted file mode 100644 index 99fc738b41..0000000000 --- a/src/common/app-paths/app-paths-channel.injectable.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * 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 type { AppPaths } from "./app-path-injection-token"; -import type { RequestChannel } from "../utils/channel/request-channel-injection-token"; -import { messageChannelInjectionToken } from "../utils/channel/message-channel-injection-token"; - -export type AppPathsChannel = RequestChannel; - -const appPathsChannelInjectable = getInjectable({ - id: "app-paths-channel", - - instantiate: (): AppPathsChannel => ({ - id: "app-paths", - }), - - injectionToken: messageChannelInjectionToken, -}); - -export default appPathsChannelInjectable; diff --git a/src/common/app-paths/app-paths-channel.ts b/src/common/app-paths/app-paths-channel.ts new file mode 100644 index 0000000000..4502569d3b --- /dev/null +++ b/src/common/app-paths/app-paths-channel.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { AppPaths } from "./app-path-injection-token"; +import type { RequestChannel } from "../utils/channel/request-channel-listener-injection-token"; + +export type AppPathsChannel = RequestChannel; + +export const appPathsChannel: AppPathsChannel = { + id: "app-paths", +}; + diff --git a/src/common/app-paths/app-paths.test.ts b/src/common/app-paths/app-paths.test.ts index b5ec33059c..ff4bd88988 100644 --- a/src/common/app-paths/app-paths.test.ts +++ b/src/common/app-paths/app-paths.test.ts @@ -7,7 +7,6 @@ import { appPathsInjectionToken } from "./app-path-injection-token"; import getElectronAppPathInjectable from "../../main/app-paths/get-electron-app-path/get-electron-app-path.injectable"; import type { PathName } from "./app-path-names"; import setElectronAppPathInjectable from "../../main/app-paths/set-electron-app-path/set-electron-app-path.injectable"; -import appNameInjectable from "../../main/app-paths/app-name/app-name.injectable"; import directoryForIntegrationTestingInjectable from "../../main/app-paths/directory-for-integration-testing/directory-for-integration-testing.injectable"; import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; @@ -53,8 +52,6 @@ describe("app-paths", () => { defaultAppPathsStub[key] = path; }, ); - - mainDi.override(appNameInjectable, () => "some-app-name"); }); }); @@ -88,7 +85,7 @@ describe("app-paths", () => { recent: "some-recent", temp: "some-temp", videos: "some-videos", - userData: "some-app-data/some-app-name", + userData: "some-app-data/some-product-name", }); }); @@ -111,7 +108,7 @@ describe("app-paths", () => { recent: "some-recent", temp: "some-temp", videos: "some-videos", - userData: "some-app-data/some-app-name", + userData: "some-app-data/some-product-name", }); }); }); @@ -137,7 +134,7 @@ describe("app-paths", () => { expect({ appData, userData }).toEqual({ appData: "some-integration-testing-app-data", - userData: `some-integration-testing-app-data/some-app-name`, + userData: `some-integration-testing-app-data/some-product-name`, }); }); @@ -146,7 +143,7 @@ describe("app-paths", () => { expect({ appData, userData }).toEqual({ appData: "some-integration-testing-app-data", - userData: "some-integration-testing-app-data/some-app-name", + userData: "some-integration-testing-app-data/some-product-name", }); }); }); diff --git a/src/common/app-paths/directory-for-binaries/directory-for-binaries.injectable.ts b/src/common/app-paths/directory-for-binaries/directory-for-binaries.injectable.ts index 5f6b14f5ba..f8a55b041c 100644 --- a/src/common/app-paths/directory-for-binaries/directory-for-binaries.injectable.ts +++ b/src/common/app-paths/directory-for-binaries/directory-for-binaries.injectable.ts @@ -4,16 +4,16 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import directoryForUserDataInjectable from "../directory-for-user-data/directory-for-user-data.injectable"; -import getAbsolutePathInjectable from "../../path/get-absolute-path.injectable"; +import joinPathsInjectable from "../../path/join-paths.injectable"; const directoryForBinariesInjectable = getInjectable({ id: "directory-for-binaries", instantiate: (di) => { - const getAbsolutePath = di.inject(getAbsolutePathInjectable); + const joinPaths = di.inject(joinPathsInjectable); const directoryForUserData = di.inject(directoryForUserDataInjectable); - return getAbsolutePath(directoryForUserData, "binaries"); + return joinPaths(directoryForUserData, "binaries"); }, }); diff --git a/src/common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable.ts b/src/common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable.ts index f371860f18..d49029572e 100644 --- a/src/common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable.ts +++ b/src/common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable.ts @@ -4,19 +4,16 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import directoryForUserDataInjectable from "../directory-for-user-data/directory-for-user-data.injectable"; -import getAbsolutePathInjectable from "../../path/get-absolute-path.injectable"; +import joinPathsInjectable from "../../path/join-paths.injectable"; const directoryForKubeConfigsInjectable = getInjectable({ id: "directory-for-kube-configs", instantiate: (di) => { - const getAbsolutePath = di.inject(getAbsolutePathInjectable); + const joinPaths = di.inject(joinPathsInjectable); const directoryForUserData = di.inject(directoryForUserDataInjectable); - return getAbsolutePath( - directoryForUserData, - "kubeconfigs", - ); + return joinPaths(directoryForUserData, "kubeconfigs"); }, }); diff --git a/src/common/app-paths/directory-for-kubectl-binaries/directory-for-kubectl-binaries.injectable.ts b/src/common/app-paths/directory-for-kubectl-binaries/directory-for-kubectl-binaries.injectable.ts index 4b6ce56601..c1ef1b23f5 100644 --- a/src/common/app-paths/directory-for-kubectl-binaries/directory-for-kubectl-binaries.injectable.ts +++ b/src/common/app-paths/directory-for-kubectl-binaries/directory-for-kubectl-binaries.injectable.ts @@ -4,17 +4,16 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import directoryForBinariesInjectable from "../directory-for-binaries/directory-for-binaries.injectable"; -import getAbsolutePathInjectable from "../../path/get-absolute-path.injectable"; +import joinPathsInjectable from "../../path/join-paths.injectable"; const directoryForKubectlBinariesInjectable = getInjectable({ id: "directory-for-kubectl-binaries", instantiate: (di) => { - const getAbsolutePath = di.inject(getAbsolutePathInjectable); + const joinPaths = di.inject(joinPathsInjectable); const directoryForBinaries = di.inject(directoryForBinariesInjectable); - - return getAbsolutePath(directoryForBinaries, "kubectl"); + return joinPaths(directoryForBinaries, "kubectl"); }, }); diff --git a/src/common/app-paths/directory-for-logs.injectable.ts b/src/common/app-paths/directory-for-logs.injectable.ts new file mode 100644 index 0000000000..e9abc35c44 --- /dev/null +++ b/src/common/app-paths/directory-for-logs.injectable.ts @@ -0,0 +1,13 @@ +/** + * 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 appPathsInjectable from "./app-paths.injectable"; + +const directoryForLogsInjectable = getInjectable({ + id: "directory-for-logs", + instantiate: (di) => di.inject(appPathsInjectable).logs, +}); + +export default directoryForLogsInjectable; diff --git a/src/common/app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable.ts b/src/common/app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable.ts index 6f22aa32f2..5d53e506bd 100644 --- a/src/common/app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable.ts +++ b/src/common/app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable.ts @@ -4,17 +4,16 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import directoryForKubeConfigsInjectable from "../directory-for-kube-configs/directory-for-kube-configs.injectable"; -import getAbsolutePathInjectable from "../../path/get-absolute-path.injectable"; +import joinPathsInjectable from "../../path/join-paths.injectable"; const getCustomKubeConfigDirectoryInjectable = getInjectable({ id: "get-custom-kube-config-directory", instantiate: (di) => { const directoryForKubeConfigs = di.inject(directoryForKubeConfigsInjectable); - const getAbsolutePath = di.inject(getAbsolutePathInjectable); + const joinPaths = di.inject(joinPathsInjectable); - return (directoryName: string) => - getAbsolutePath(directoryForKubeConfigs, directoryName); + return (directoryName: string) => joinPaths(directoryForKubeConfigs, directoryName); }, }); diff --git a/src/common/application-update/restart-and-install-update-channel/restart-and-install-update-channel.injectable.ts b/src/common/application-update/restart-and-install-update-channel/restart-and-install-update-channel.injectable.ts deleted file mode 100644 index b6d811e101..0000000000 --- a/src/common/application-update/restart-and-install-update-channel/restart-and-install-update-channel.injectable.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * 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 type { MessageChannel } from "../../utils/channel/message-channel-injection-token"; -import { messageChannelInjectionToken } from "../../utils/channel/message-channel-injection-token"; - -export type RestartAndInstallUpdateChannel = MessageChannel; - -const restartAndInstallUpdateChannel = getInjectable({ - id: "restart-and-install-update-channel", - - instantiate: (): RestartAndInstallUpdateChannel => ({ - id: "restart-and-install-update-channel", - }), - - injectionToken: messageChannelInjectionToken, -}); - -export default restartAndInstallUpdateChannel; diff --git a/src/common/application-update/selected-update-channel/default-update-channel.injectable.ts b/src/common/application-update/selected-update-channel/default-update-channel.injectable.ts deleted file mode 100644 index 90dc22457a..0000000000 --- a/src/common/application-update/selected-update-channel/default-update-channel.injectable.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * 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 appSemanticVersionInjectable from "../../vars/app-semantic-version.injectable"; -import type { UpdateChannelId } from "../update-channels"; -import { updateChannels } from "../update-channels"; - -const defaultUpdateChannelInjectable = getInjectable({ - id: "default-update-channel", - - instantiate: (di) => { - const appSemanticVersion = di.inject(appSemanticVersionInjectable); - const currentReleaseChannel = appSemanticVersion.prerelease[0]?.toString(); - - if (currentReleaseChannel in updateChannels) { - return updateChannels[currentReleaseChannel as UpdateChannelId]; - } - - return updateChannels.latest; - }, -}); - -export default defaultUpdateChannelInjectable; diff --git a/src/common/base-store.ts b/src/common/base-store.ts index 2c2f66dd23..92383b328d 100644 --- a/src/common/base-store.ts +++ b/src/common/base-store.ts @@ -19,7 +19,7 @@ import { kebabCase } from "lodash"; import { getLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; import directoryForUserDataInjectable from "./app-paths/directory-for-user-data/directory-for-user-data.injectable"; import getConfigurationFileModelInjectable from "./get-configuration-file-model/get-configuration-file-model.injectable"; -import appVersionInjectable from "./vars/app-version.injectable"; +import storeMigrationVersionInjectable from "./vars/store-migration-version.injectable"; export interface BaseStoreParams extends ConfOptions { syncOptions?: { @@ -60,7 +60,7 @@ export abstract class BaseStore extends Singleton { this.storeConfig = getConfigurationFileModel({ projectName: "lens", - projectVersion: di.inject(appVersionInjectable), + projectVersion: di.inject(storeMigrationVersionInjectable), cwd: this.cwd(), ...this.params, }); diff --git a/src/common/catalog-entities/web-link.ts b/src/common/catalog-entities/web-link.ts index 35dc86be57..dade63af16 100644 --- a/src/common/catalog-entities/web-link.ts +++ b/src/common/catalog-entities/web-link.ts @@ -3,9 +3,10 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; import type { CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; import { CatalogCategory, CatalogEntity, categoryVersion } from "../catalog/catalog-entity"; -import { productName } from "../vars"; +import productNameInjectable from "../vars/product-name.injectable"; import { WeblinkStore } from "../weblink-store"; export type WebLinkStatusPhase = "available" | "unavailable"; @@ -30,6 +31,9 @@ export class WebLink extends CatalogEntity { + const registry = di.inject(catalogCategoryRegistryInjectable); + + return computed(() => [...registry.filteredItems]); + }, +}); + +export default filteredCategoriesInjectable; diff --git a/src/common/certificate-authorities/inject-system-cas.injectable.ts b/src/common/certificate-authorities/inject-system-cas.injectable.ts new file mode 100644 index 0000000000..f75dcd6c70 --- /dev/null +++ b/src/common/certificate-authorities/inject-system-cas.injectable.ts @@ -0,0 +1,60 @@ +/** + * 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 { globalAgent } from "https"; +import { requestSystemCAsInjectionToken } from "./request-system-cas-token"; + +// DST Root CA X3, which was expired on 9.30.2021 +const DSTRootCAX3 = "-----BEGIN CERTIFICATE-----\nMIIDSjCCAjKgAwIBAgIQRK+wgNajJ7qJMDmGLvhAazANBgkqhkiG9w0BAQUFADA/\nMSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT\nDkRTVCBSb290IENBIFgzMB4XDTAwMDkzMDIxMTIxOVoXDTIxMDkzMDE0MDExNVow\nPzEkMCIGA1UEChMbRGlnaXRhbCBTaWduYXR1cmUgVHJ1c3QgQ28uMRcwFQYDVQQD\nEw5EU1QgUm9vdCBDQSBYMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB\nAN+v6ZdQCINXtMxiZfaQguzH0yxrMMpb7NnDfcdAwRgUi+DoM3ZJKuM/IUmTrE4O\nrz5Iy2Xu/NMhD2XSKtkyj4zl93ewEnu1lcCJo6m67XMuegwGMoOifooUMM0RoOEq\nOLl5CjH9UL2AZd+3UWODyOKIYepLYYHsUmu5ouJLGiifSKOeDNoJjj4XLh7dIN9b\nxiqKqy69cK3FCxolkHRyxXtqqzTWMIn/5WgTe1QLyNau7Fqckh49ZLOMxt+/yUFw\n7BZy1SbsOFU5Q9D8/RhcQPGX69Wam40dutolucbY38EVAjqr2m7xPi71XAicPNaD\naeQQmxkqtilX4+U9m5/wAl0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV\nHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFMSnsaR7LHH62+FLkHX/xBVghYkQMA0GCSqG\nSIb3DQEBBQUAA4IBAQCjGiybFwBcqR7uKGY3Or+Dxz9LwwmglSBd49lZRNI+DT69\nikugdB/OEIKcdBodfpga3csTS7MgROSR6cz8faXbauX+5v3gTt23ADq1cEmv8uXr\nAvHRAosZy5Q6XkjEGB5YGV8eAlrwDPGxrancWYaLbumR9YbK+rlmM6pZW87ipxZz\nR8srzJmwN0jP41ZL9c8PDHIyh8bwRLtTcm1D9SZImlJnt1ir/md2cXjbDaJWFBM5\nJDGFoqgCWjBH4d1QB7wCCZAA62RjYJsWvIjJEubSfZGL+T0yjWW06XyxV3bqxbYo\nOb8VZRzI9neWagqNdwvYkQsEjgfbKbYK7p2CNTUQ\n-----END CERTIFICATE-----\n"; + +function isCertActive(cert: string) { + const isExpired = typeof cert !== "string" || cert.includes(DSTRootCAX3); + + return !isExpired; +} + +const injectSystemCAsInjectable = getInjectable({ + id: "inject-system-cas", + instantiate: (di) => { + const requestSystemCAs = di.inject(requestSystemCAsInjectionToken); + + return async () => { + const certs = await requestSystemCAs(); + + if (certs.length === 0) { + // Leave the global option alone + return; + } + + const cas = (() => { + if (Array.isArray(globalAgent.options.ca)) { + return globalAgent.options.ca; + } + + if (globalAgent.options.ca) { + return [globalAgent.options.ca]; + } + + return []; + })(); + + for (const cert of certs) { + if (!isCertActive(cert)) { + continue; + } + + if (!cas.includes(cert)) { + cas.push(cert); + } + } + + globalAgent.options.ca = cas; + }; + }, +}); + +export default injectSystemCAsInjectable; + diff --git a/src/common/certificate-authorities/request-system-cas-token.ts b/src/common/certificate-authorities/request-system-cas-token.ts new file mode 100644 index 0000000000..c69b0bd8b0 --- /dev/null +++ b/src/common/certificate-authorities/request-system-cas-token.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; + +export const requestSystemCAsInjectionToken = getInjectionToken<() => Promise>({ + id: "request-system-cas-token", +}); diff --git a/src/common/certificate-authorities/request-system-cas.injectable.darwin.ts b/src/common/certificate-authorities/request-system-cas.injectable.darwin.ts new file mode 100644 index 0000000000..c471c954e4 --- /dev/null +++ b/src/common/certificate-authorities/request-system-cas.injectable.darwin.ts @@ -0,0 +1,57 @@ +/** + * 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 execFileInjectable from "../fs/exec-file.injectable"; +import loggerInjectable from "../logger.injectable"; +import type { AsyncResult } from "../utils/async-result"; +import { requestSystemCAsInjectionToken } from "./request-system-cas-token"; + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Cheatsheet#other_assertions +const certSplitPattern = /(?=-----BEGIN\sCERTIFICATE-----)/g; + +const requestSystemCAsInjectable = getInjectable({ + id: "request-system-cas", + instantiate: (di) => { + const execFile = di.inject(execFileInjectable); + const logger = di.inject(loggerInjectable); + + const execSecurity = async (...args: string[]): Promise> => { + const result = await execFile("/usr/bin/security", args); + + if (!result.callWasSuccessful) { + return { + callWasSuccessful: false, + error: result.error.stderr || result.error.message, + }; + } + + return { + callWasSuccessful: true, + response: result.response.split(certSplitPattern), + }; + }; + + return async () => { + const [trustedResult, rootCAResult] = await Promise.all([ + execSecurity("find-certificate", "-a", "-p"), + execSecurity("find-certificate", "-a", "-p", "/System/Library/Keychains/SystemRootCertificates.keychain"), + ]); + + if (!trustedResult.callWasSuccessful) { + logger.warn(`[INJECT-CAS]: Error retreiving trusted CAs: ${trustedResult.error}`); + } else if (!rootCAResult.callWasSuccessful) { + logger.warn(`[INJECT-CAS]: Error retreiving root CAs: ${rootCAResult.error}`); + } else { + return [...new Set([...trustedResult.response, ...rootCAResult.response])]; + } + + return []; + }; + }, + causesSideEffects: true, + injectionToken: requestSystemCAsInjectionToken, +}); + +export default requestSystemCAsInjectable; diff --git a/src/common/certificate-authorities/request-system-cas.injectable.linux.ts b/src/common/certificate-authorities/request-system-cas.injectable.linux.ts new file mode 100644 index 0000000000..1d7bf10350 --- /dev/null +++ b/src/common/certificate-authorities/request-system-cas.injectable.linux.ts @@ -0,0 +1,14 @@ +/** + * 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 { requestSystemCAsInjectionToken } from "./request-system-cas-token"; + +const requestSystemCAsInjectable = getInjectable({ + id: "request-system-cas", + instantiate: () => async () => [], + injectionToken: requestSystemCAsInjectionToken, +}); + +export default requestSystemCAsInjectable; diff --git a/src/common/certificate-authorities/request-system-cas.injectable.testing-env.ts b/src/common/certificate-authorities/request-system-cas.injectable.testing-env.ts new file mode 100644 index 0000000000..1d7bf10350 --- /dev/null +++ b/src/common/certificate-authorities/request-system-cas.injectable.testing-env.ts @@ -0,0 +1,14 @@ +/** + * 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 { requestSystemCAsInjectionToken } from "./request-system-cas-token"; + +const requestSystemCAsInjectable = getInjectable({ + id: "request-system-cas", + instantiate: () => async () => [], + injectionToken: requestSystemCAsInjectionToken, +}); + +export default requestSystemCAsInjectable; diff --git a/src/common/certificate-authorities/request-system-cas.injectable.win32.ts b/src/common/certificate-authorities/request-system-cas.injectable.win32.ts new file mode 100644 index 0000000000..4940aa2a7b --- /dev/null +++ b/src/common/certificate-authorities/request-system-cas.injectable.win32.ts @@ -0,0 +1,56 @@ +/** + * 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 execFileInjectable from "../fs/exec-file.injectable"; +import loggerInjectable from "../logger.injectable"; +import { requestSystemCAsInjectionToken } from "./request-system-cas-token"; + +const pemEncoding = (hexEncodedCert: String) => { + const certData = Buffer.from(hexEncodedCert, "hex").toString("base64"); + const lines = ["-----BEGIN CERTIFICATE-----"]; + + for (let i = 0; i < certData.length; i += 64) { + lines.push(certData.substring(i, i + 64)); + } + + lines.push("-----END CERTIFICATE-----", ""); + + return lines.join("\r\n"); +}; + +const requestSystemCAsInjectable = getInjectable({ + id: "request-system-cas", + instantiate: (di) => { + const wincaRootsExePath: string = __non_webpack_require__.resolve("win-ca/lib/roots.exe"); + const execFile = di.inject(execFileInjectable); + const logger = di.inject(loggerInjectable); + + return async () => { + /** + * This needs to be done manually because for some reason calling the api from "win-ca" + * directly fails to load "child_process" correctly on renderer + */ + const result = await execFile(wincaRootsExePath, { + maxBuffer: 128 * 1024 * 1024, // 128 MiB + }); + + if (!result.callWasSuccessful) { + logger.warn(`[INJECT-CAS]: Error retreiving CAs`, result.error); + + return []; + } + + return result + .response + .split("\r\n") + .filter(Boolean) + .map(pemEncoding); + }; + }, + causesSideEffects: true, + injectionToken: requestSystemCAsInjectionToken, +}); + +export default requestSystemCAsInjectable; diff --git a/src/common/cluster-store/cluster-store.injectable.ts b/src/common/cluster-store/cluster-store.injectable.ts index 5a601e1866..3e7cf86c53 100644 --- a/src/common/cluster-store/cluster-store.injectable.ts +++ b/src/common/cluster-store/cluster-store.injectable.ts @@ -6,6 +6,7 @@ import { getInjectable } from "@ogre-tools/injectable"; import { ClusterStore } from "./cluster-store"; import { createClusterInjectionToken } from "../cluster/create-cluster-injection-token"; import readClusterConfigSyncInjectable from "./read-cluster-config.injectable"; +import emitAppEventInjectable from "../app-event-bus/emit-event.injectable"; const clusterStoreInjectable = getInjectable({ id: "cluster-store", @@ -16,6 +17,7 @@ const clusterStoreInjectable = getInjectable({ return ClusterStore.createInstance({ createCluster: di.inject(createClusterInjectionToken), readClusterConfigSync: di.inject(readClusterConfigSyncInjectable), + emitAppEvent: di.inject(emitAppEventInjectable), }); }, diff --git a/src/common/cluster-store/cluster-store.ts b/src/common/cluster-store/cluster-store.ts index 62b8cda973..7b46460013 100644 --- a/src/common/cluster-store/cluster-store.ts +++ b/src/common/cluster-store/cluster-store.ts @@ -10,7 +10,6 @@ import { BaseStore } from "../base-store"; import { Cluster } from "../cluster/cluster"; import migrations from "../../migrations/cluster-store"; import logger from "../../main/logger"; -import { appEventBus } from "../app-event-bus/event-bus"; import { ipcMainHandle } from "../ipc"; import { disposer, toJS } from "../utils"; import type { ClusterModel, ClusterId, ClusterState } from "../cluster-types"; @@ -18,6 +17,7 @@ import { requestInitialClusterStates } from "../../renderer/ipc"; import { clusterStates } from "../ipc/cluster"; import type { CreateCluster } from "../cluster/create-cluster-injection-token"; import type { ReadClusterConfigSync } from "./read-cluster-config.injectable"; +import type { EmitAppEvent } from "../app-event-bus/emit-event.injectable"; export interface ClusterStoreModel { clusters?: ClusterModel[]; @@ -26,6 +26,7 @@ export interface ClusterStoreModel { interface Dependencies { createCluster: CreateCluster; readClusterConfigSync: ReadClusterConfigSync; + emitAppEvent: EmitAppEvent; } export class ClusterStore extends BaseStore { @@ -34,7 +35,7 @@ export class ClusterStore extends BaseStore { protected disposer = disposer(); - constructor(private dependencies: Dependencies) { + constructor(private readonly dependencies: Dependencies) { super({ configName: "lens-cluster-store", accessPropertiesByDotNotation: false, // To make dots safe in cluster context names @@ -115,7 +116,7 @@ export class ClusterStore extends BaseStore { } addCluster(clusterOrModel: ClusterModel | Cluster): Cluster { - appEventBus.emit({ name: "cluster", action: "add" }); + this.dependencies.emitAppEvent({ name: "cluster", action: "add" }); const cluster = clusterOrModel instanceof Cluster ? clusterOrModel diff --git a/src/common/cluster-store/get-by-id.injectable.ts b/src/common/cluster-store/get-by-id.injectable.ts new file mode 100644 index 0000000000..534bdb5e76 --- /dev/null +++ b/src/common/cluster-store/get-by-id.injectable.ts @@ -0,0 +1,21 @@ +/** + * 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 type { ClusterId } from "../cluster-types"; +import type { Cluster } from "../cluster/cluster"; +import clusterStoreInjectable from "./cluster-store.injectable"; + +export type GetClusterById = (id: ClusterId) => Cluster | undefined; + +const getClusterByIdInjectable = getInjectable({ + id: "get-cluster-by-id", + instantiate: (di): GetClusterById => { + const store = di.inject(clusterStoreInjectable); + + return (id) => store.getById(id); + }, +}); + +export default getClusterByIdInjectable; diff --git a/src/common/cluster-types.ts b/src/common/cluster-types.ts index f0862e07b8..0cd447f0e2 100644 --- a/src/common/cluster-types.ts +++ b/src/common/cluster-types.ts @@ -3,6 +3,8 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import Joi from "joi"; + /** * JSON serializable metadata type */ @@ -27,6 +29,37 @@ export type ClusterId = string; */ export type UpdateClusterModel = Omit; +/** + * A type validator for `UpdateClusterModel` so that only expected types are present + */ +export const updateClusterModelChecker = Joi.object({ + kubeConfigPath: Joi.string() + .required() + .min(1), + contextName: Joi.string() + .required() + .min(1), + workspace: Joi.string() + .optional(), + workspaces: Joi.array() + .items(Joi.string()), + preferences: Joi.object(), + metadata: Joi.object(), + accessibleNamespaces: Joi.array() + .items(Joi.string()), + labels: Joi.object().pattern(Joi.string(), Joi.string()), +}); + +/** + * A type validator for just the `id` fields of `ClusterModel`. The rest is + * covered by `updateClusterModelChecker` + */ +export const clusterModelIdChecker = Joi.object>({ + id: Joi.string() + .required() + .min(1), +}); + /** * The model for passing cluster data around, including to disk */ @@ -162,13 +195,6 @@ export enum ClusterMetricsResourceType { */ export const initialNodeShellImage = "docker.io/alpine:3.13"; -/** - * The arguments for requesting to refresh a cluster's metadata - */ -export interface ClusterRefreshOptions { - refreshMetadata?: boolean; -} - /** * The data representing a cluster's state, for passing between main and renderer */ diff --git a/src/common/cluster/authorization-namespace-review.injectable.ts b/src/common/cluster/authorization-namespace-review.injectable.ts new file mode 100644 index 0000000000..aa78453569 --- /dev/null +++ b/src/common/cluster/authorization-namespace-review.injectable.ts @@ -0,0 +1,87 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeConfig } from "@kubernetes/client-node"; +import { AuthorizationV1Api } from "@kubernetes/client-node"; +import { getInjectable } from "@ogre-tools/injectable"; +import type { Logger } from "../logger"; +import loggerInjectable from "../logger.injectable"; +import type { KubeApiResource } from "../rbac"; + +/** + * Requests the permissions for actions on the kube cluster + * @param namespace The namespace of the resources + * @param availableResources List of available resources in the cluster to resolve glob values fir api groups + * @returns list of allowed resources names + */ +export type RequestNamespaceResources = (namespace: string, availableResources: KubeApiResource[]) => Promise; + +/** + * @param proxyConfig This config's `currentContext` field must be set, and will be used as the target cluster + */ +export type AuthorizationNamespaceReview = (proxyConfig: KubeConfig) => RequestNamespaceResources; + +interface Dependencies { + logger: Logger; +} + +const authorizationNamespaceReview = ({ logger }: Dependencies): AuthorizationNamespaceReview => { + return (proxyConfig) => { + + const api = proxyConfig.makeApiClient(AuthorizationV1Api); + + return async (namespace, availableResources) => { + try { + const { body } = await api.createSelfSubjectRulesReview({ + apiVersion: "authorization.k8s.io/v1", + kind: "SelfSubjectRulesReview", + spec: { namespace }, + }); + + const resources = new Set(); + + body.status?.resourceRules.forEach(resourceRule => { + if (!resourceRule.verbs.some(verb => ["*", "list"].includes(verb)) || !resourceRule.resources) { + return; + } + + const apiGroups = resourceRule.apiGroups; + + if (resourceRule.resources.length === 1 && resourceRule.resources[0] === "*" && apiGroups) { + if (apiGroups[0] === "*") { + availableResources.forEach(resource => resources.add(resource.apiName)); + } else { + availableResources.forEach((apiResource)=> { + if (apiGroups.includes(apiResource.group || "")) { + resources.add(apiResource.apiName); + } + }); + } + } else { + resourceRule.resources.forEach(resource => resources.add(resource)); + } + + }); + + return [...resources]; + } catch (error) { + logger.error(`[AUTHORIZATION-NAMESPACE-REVIEW]: failed to create subject rules review: ${error}`, { namespace }); + + return []; + } + }; + }; +}; + +const authorizationNamespaceReviewInjectable = getInjectable({ + id: "authorization-namespace-review", + instantiate: (di) => { + const logger = di.inject(loggerInjectable); + + return authorizationNamespaceReview({ logger }); + }, +}); + +export default authorizationNamespaceReviewInjectable; diff --git a/src/common/cluster/authorization-review.injectable.ts b/src/common/cluster/authorization-review.injectable.ts index c622893b63..4c9b83330d 100644 --- a/src/common/cluster/authorization-review.injectable.ts +++ b/src/common/cluster/authorization-review.injectable.ts @@ -5,42 +5,55 @@ import type { KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"; import { AuthorizationV1Api } from "@kubernetes/client-node"; -import logger from "../logger"; import { getInjectable } from "@ogre-tools/injectable"; +import type { Logger } from "../logger"; +import loggerInjectable from "../logger.injectable"; +/** + * Requests the permissions for actions on the kube cluster + * @param resourceAttributes The descriptor of the action that is desired to be known if it is allowed + * @returns `true` if the actions described are allowed + */ export type CanI = (resourceAttributes: V1ResourceAttributes) => Promise; /** * @param proxyConfig This config's `currentContext` field must be set, and will be used as the target cluster - */ -export function authorizationReview(proxyConfig: KubeConfig): CanI { - const api = proxyConfig.makeApiClient(AuthorizationV1Api); + */ +export type AuthorizationReview = (proxyConfig: KubeConfig) => CanI; - /** - * Requests the permissions for actions on the kube cluster - * @param resourceAttributes The descriptor of the action that is desired to be known if it is allowed - * @returns `true` if the actions described are allowed - */ - return async (resourceAttributes: V1ResourceAttributes): Promise => { - try { - const { body } = await api.createSelfSubjectAccessReview({ - apiVersion: "authorization.k8s.io/v1", - kind: "SelfSubjectAccessReview", - spec: { resourceAttributes }, - }); - - return body.status?.allowed ?? false; - } catch (error) { - logger.error(`[AUTHORIZATION-REVIEW]: failed to create access review: ${error}`, { resourceAttributes }); - - return false; - } - }; +interface Dependencies { + logger: Logger; } +const authorizationReview = ({ logger }: Dependencies): AuthorizationReview => { + return (proxyConfig) => { + const api = proxyConfig.makeApiClient(AuthorizationV1Api); + + return async (resourceAttributes: V1ResourceAttributes): Promise => { + try { + const { body } = await api.createSelfSubjectAccessReview({ + apiVersion: "authorization.k8s.io/v1", + kind: "SelfSubjectAccessReview", + spec: { resourceAttributes }, + }); + + return body.status?.allowed ?? false; + } catch (error) { + logger.error(`[AUTHORIZATION-REVIEW]: failed to create access review: ${error}`, { resourceAttributes }); + + return false; + } + }; + }; +}; + const authorizationReviewInjectable = getInjectable({ id: "authorization-review", - instantiate: () => authorizationReview, + instantiate: (di) => { + const logger = di.inject(loggerInjectable); + + return authorizationReview({ logger }); + }, }); export default authorizationReviewInjectable; diff --git a/src/common/cluster/cluster.ts b/src/common/cluster/cluster.ts index f1a943732c..7f27025190 100644 --- a/src/common/cluster/cluster.ts +++ b/src/common/cluster/cluster.ts @@ -9,14 +9,13 @@ import type { KubeConfig } from "@kubernetes/client-node"; import { HttpError } from "@kubernetes/client-node"; import type { Kubectl } from "../../main/kubectl/kubectl"; import type { KubeconfigManager } from "../../main/kubeconfig-manager/kubeconfig-manager"; -import { loadConfigFromFile } from "../kube-helpers"; import type { KubeApiResource, KubeResource } from "../rbac"; import { apiResourceRecord, apiResources } from "../rbac"; import type { VersionDetector } from "../../main/cluster-detectors/version-detector"; import type { DetectorRegistry } from "../../main/cluster-detectors/detector-registry"; import plimit from "p-limit"; -import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel, KubeAuthUpdate, ClusterConfigData } from "../cluster-types"; -import { ClusterMetadataKey, initialNodeShellImage, ClusterStatus } from "../cluster-types"; +import type { ClusterState, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel, KubeAuthUpdate, ClusterConfigData } from "../cluster-types"; +import { ClusterMetadataKey, initialNodeShellImage, ClusterStatus, clusterModelIdChecker, updateClusterModelChecker } from "../cluster-types"; import { disposer, isDefined, isRequestError, toJS } from "../utils"; import type { Response } from "request"; import { clusterListNamespaceForbiddenChannel } from "../ipc/cluster"; @@ -25,6 +24,9 @@ import type { ListNamespaces } from "./list-namespaces.injectable"; import assert from "assert"; import type { Logger } from "../logger"; import type { BroadcastMessage } from "../ipc/broadcast-message.injectable"; +import type { LoadConfigfromFile } from "../kube-helpers/load-config-from-file.injectable"; +import type { RequestNamespaceResources } from "./authorization-namespace-review.injectable"; +import type { RequestListApiResources } from "./list-api-resources.injectable"; export interface ClusterDependencies { readonly directoryForKubeConfigs: string; @@ -34,9 +36,12 @@ export interface ClusterDependencies { createContextHandler: (cluster: Cluster) => ClusterContextHandler; createKubectl: (clusterVersion: string) => Kubectl; createAuthorizationReview: (config: KubeConfig) => CanI; + createAuthorizationNamespaceReview: (config: KubeConfig) => RequestNamespaceResources; + createListApiResources: (cluster: Cluster) => RequestListApiResources; createListNamespaces: (config: KubeConfig) => ListNamespaces; createVersionDetector: (cluster: Cluster) => VersionDetector; broadcastMessage: BroadcastMessage; + loadConfigfromFile: LoadConfigfromFile; } /** @@ -237,9 +242,16 @@ export class Cluster implements ClusterModel, ClusterState { return this.preferences.defaultNamespace; } - constructor(private readonly dependencies: ClusterDependencies, model: ClusterModel, configData: ClusterConfigData) { + constructor(private readonly dependencies: ClusterDependencies, { id, ...model }: ClusterModel, configData: ClusterConfigData) { makeObservable(this); - this.id = model.id; + + const { error } = clusterModelIdChecker.validate({ id }); + + if (error) { + throw error; + } + + this.id = id; this.updateModel(model); this.apiUrl = configData.clusterServerUrl; @@ -261,6 +273,12 @@ export class Cluster implements ClusterModel, ClusterState { @action updateModel(model: UpdateClusterModel) { // Note: do not assign ID as that should never be updated + const { error } = updateClusterModelChecker.validate(model, { allowUnknown: true }); + + if (error) { + throw error; + } + this.kubeConfigPath = model.kubeConfigPath; this.contextName = model.contextName; @@ -295,7 +313,7 @@ export class Cluster implements ClusterModel, ClusterState { protected bindEvents() { this.dependencies.logger.info(`[CLUSTER]: bind events`, this.getMeta()); const refreshTimer = setInterval(() => !this.disconnected && this.refresh(), 30000); // every 30s - const refreshMetadataTimer = setInterval(() => !this.disconnected && this.refreshMetadata(), 900000); // every 15 minutes + const refreshMetadataTimer = setInterval(() => this.available && this.refreshAccessibilityAndMetadata(), 900000); // every 15 minutes this.eventsDisposer.push( reaction(() => this.getState(), state => this.pushState(state)), @@ -425,69 +443,71 @@ export class Cluster implements ClusterModel, ClusterState { /** * @internal - * @param opts refresh options */ @action - async refresh(opts: ClusterRefreshOptions = {}) { + async refresh() { this.dependencies.logger.info(`[CLUSTER]: refresh`, this.getMeta()); await this.refreshConnectionStatus(); - - if (this.accessible) { - await this.refreshAccessibility(); - - if (opts.refreshMetadata) { - this.refreshMetadata(); - } - } this.pushState(); } /** * @internal */ - @action - async refreshMetadata() { - this.dependencies.logger.info(`[CLUSTER]: refreshMetadata`, this.getMeta()); - const metadata = await this.dependencies.detectorRegistry.detectForCluster(this); - const existingMetadata = this.metadata; - - this.metadata = Object.assign(existingMetadata, metadata); + @action + async refreshAccessibilityAndMetadata() { + await this.refreshAccessibility(); + await this.refreshMetadata(); } - /** + /** * @internal */ - private async refreshAccessibility(): Promise { - const proxyConfig = await this.getProxyKubeconfig(); - const canI = this.dependencies.createAuthorizationReview(proxyConfig); + async refreshMetadata() { + this.dependencies.logger.info(`[CLUSTER]: refreshMetadata`, this.getMeta()); + const metadata = await this.dependencies.detectorRegistry.detectForCluster(this); + const existingMetadata = this.metadata; - this.isAdmin = await canI({ - namespace: "kube-system", - resource: "*", - verb: "create", - }); - this.isGlobalWatchEnabled = await canI({ - verb: "watch", - resource: "*", - }); - this.allowedNamespaces = await this.getAllowedNamespaces(proxyConfig); - this.allowedResources = await this.getAllowedResources(canI); - this.ready = true; - } + this.metadata = Object.assign(existingMetadata, metadata); + } + + /** + * @internal + */ + private async refreshAccessibility(): Promise { + this.dependencies.logger.info(`[CLUSTER]: refreshAccessibility`, this.getMeta()); + const proxyConfig = await this.getProxyKubeconfig(); + const canI = this.dependencies.createAuthorizationReview(proxyConfig); + const requestNamespaceResources = this.dependencies.createAuthorizationNamespaceReview(proxyConfig); + const listApiResources = this.dependencies.createListApiResources(this); + + this.isAdmin = await canI({ + namespace: "kube-system", + resource: "*", + verb: "create", + }); + this.isGlobalWatchEnabled = await canI({ + verb: "watch", + resource: "*", + }); + this.allowedNamespaces = await this.getAllowedNamespaces(proxyConfig); + this.allowedResources = await this.getAllowedResources(listApiResources, requestNamespaceResources); + this.ready = true; + } /** * @internal */ @action - async refreshConnectionStatus() { - const connectionStatus = await this.getConnectionStatus(); + async refreshConnectionStatus() { + const connectionStatus = await this.getConnectionStatus(); - this.online = connectionStatus > ClusterStatus.Offline; - this.accessible = connectionStatus == ClusterStatus.AccessGranted; - } + this.online = connectionStatus > ClusterStatus.Offline; + this.accessible = connectionStatus == ClusterStatus.AccessGranted; + } async getKubeconfig(): Promise { - const { config } = await loadConfigFromFile(this.kubeConfigPath); + const { config } = await this.dependencies.loadConfigfromFile(this.kubeConfigPath); return config; } @@ -497,7 +517,7 @@ export class Cluster implements ClusterModel, ClusterState { */ async getProxyKubeconfig(): Promise { const proxyKCPath = await this.getProxyKubeconfigPath(); - const { config } = await loadConfigFromFile(proxyKCPath); + const { config } = await this.dependencies.loadConfigfromFile(proxyKCPath); return config; } @@ -653,32 +673,48 @@ export class Cluster implements ClusterModel, ClusterState { } } - protected async getAllowedResources(canI: CanI) { + protected async getAllowedResources(listApiResources:RequestListApiResources, requestNamespaceResources: RequestNamespaceResources) { try { if (!this.allowedNamespaces.length) { return []; } - const resources = apiResources.filter((resource) => this.resourceAccessStatuses.get(resource) === undefined); - const apiLimit = plimit(5); // 5 concurrent api requests - const requests = []; - for (const apiResource of resources) { - requests.push(apiLimit(async () => { - for (const namespace of this.allowedNamespaces.slice(0, 10)) { - if (!this.resourceAccessStatuses.get(apiResource)) { - const result = await canI({ - resource: apiResource.apiName, - group: apiResource.group, - verb: "list", - namespace, - }); + const unknownResources = new Map(apiResources.map(resource => ([resource.apiName, resource]))); - this.resourceAccessStatuses.set(apiResource, result); + const availableResources = await listApiResources(); + const availableResourcesNames = new Set(availableResources.map(apiResource => apiResource.apiName)); + + [...unknownResources.values()].map(unknownResource => { + if (!availableResourcesNames.has(unknownResource.apiName)) { + this.resourceAccessStatuses.set(unknownResource, false); + unknownResources.delete(unknownResource.apiName); + } + }); + + if (unknownResources.size > 0) { + const apiLimit = plimit(5); // 5 concurrent api requests + + await Promise.all(this.allowedNamespaces.map(namespace => apiLimit(async () => { + if (unknownResources.size === 0) { + return; + } + + const namespaceResources = await requestNamespaceResources(namespace, availableResources); + + for (const resourceName of namespaceResources) { + const unknownResource = unknownResources.get(resourceName); + + if (unknownResource) { + this.resourceAccessStatuses.set(unknownResource, true); + unknownResources.delete(resourceName); } } - })); + }))); + + for (const forbiddenResource of unknownResources.values()) { + this.resourceAccessStatuses.set(forbiddenResource, false); + } } - await Promise.all(requests); return apiResources .filter((resource) => this.resourceAccessStatuses.get(resource)) diff --git a/src/common/cluster/list-api-resources.injectable.ts b/src/common/cluster/list-api-resources.injectable.ts new file mode 100644 index 0000000000..ed9d5c9c39 --- /dev/null +++ b/src/common/cluster/list-api-resources.injectable.ts @@ -0,0 +1,91 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { + V1APIGroupList, + V1APIResourceList, + V1APIVersions, +} from "@kubernetes/client-node"; +import { getInjectable } from "@ogre-tools/injectable"; +import type { K8sRequest } from "../../main/k8s-request.injectable"; +import k8SRequestInjectable from "../../main/k8s-request.injectable"; +import type { Logger } from "../logger"; +import loggerInjectable from "../logger.injectable"; +import type { KubeApiResource, KubeResource } from "../rbac"; +import type { Cluster } from "./cluster"; +import plimit from "p-limit"; + +export type RequestListApiResources = () => Promise; + +/** + * @param proxyConfig This config's `currentContext` field must be set, and will be used as the target cluster + */ +export type ListApiResources = (cluster: Cluster) => RequestListApiResources; + +interface Dependencies { + logger: Logger; + k8sRequest: K8sRequest; +} + +const listApiResources = ({ k8sRequest, logger }: Dependencies): ListApiResources => { + return (cluster) => { + const clusterRequest = (path: string) => k8sRequest(cluster, path); + const apiLimit = plimit(5); + + return async () => { + const resources: KubeApiResource[] = []; + + try { + const resourceListGroups:{ group:string;path:string }[] = []; + + await Promise.all( + [ + clusterRequest("/api").then((response:V1APIVersions)=>response.versions.forEach(version => resourceListGroups.push({ group:version, path:`/api/${version}` }))), + clusterRequest("/apis").then((response:V1APIGroupList) => response.groups.forEach(group => { + const preferredVersion = group.preferredVersion?.groupVersion; + + if (preferredVersion) { + resourceListGroups.push({ group:group.name, path:`/apis/${preferredVersion}` }); + } + })), + ], + ); + + await Promise.all( + resourceListGroups.map(({ group, path }) => apiLimit(async () => { + const apiResources:V1APIResourceList = await clusterRequest(path); + + if (apiResources.resources) { + resources.push( + ...apiResources.resources.filter(resource => resource.verbs.includes("list")).map((resource) => ({ + apiName: resource.name as KubeResource, + kind: resource.kind, + group, + })), + ); + } + }), + ), + ); + } catch (error) { + logger.error(`[LIST-API-RESOURCES]: failed to list api resources: ${error}`); + } + + return resources; + }; + }; +}; + +const listApiResourcesInjectable = getInjectable({ + id: "list-api-resources", + instantiate: (di) => { + const k8sRequest = di.inject(k8SRequestInjectable); + const logger = di.inject(loggerInjectable); + + return listApiResources({ k8sRequest, logger }); + }, +}); + +export default listApiResourcesInjectable; diff --git a/src/common/cluster/visibility-channel.ts b/src/common/cluster/visibility-channel.ts new file mode 100644 index 0000000000..8a1a297ff2 --- /dev/null +++ b/src/common/cluster/visibility-channel.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ClusterId } from "../cluster-types"; +import type { MessageChannel } from "../utils/channel/message-channel-listener-injection-token"; + +export const clusterVisibilityChannel: MessageChannel = { + id: "cluster-visibility", +}; diff --git a/src/common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable.ts b/src/common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable.ts index 4bb5cb2495..11a8f3f7de 100644 --- a/src/common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable.ts +++ b/src/common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable.ts @@ -4,16 +4,16 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; -import getAbsolutePathInjectable from "../path/get-absolute-path.injectable"; +import joinPathsInjectable from "../path/join-paths.injectable"; const directoryForLensLocalStorageInjectable = getInjectable({ id: "directory-for-lens-local-storage", instantiate: (di) => { - const getAbsolutePath = di.inject(getAbsolutePathInjectable); + const joinPaths = di.inject(joinPathsInjectable); const directoryForUserData = di.inject(directoryForUserDataInjectable); - return getAbsolutePath( + return joinPaths( directoryForUserData, "lens-local-storage", ); diff --git a/src/common/error-reporting/initialize-sentry-reporting.global-override-for-injectable.ts b/src/common/error-reporting/initialize-sentry-reporting.global-override-for-injectable.ts new file mode 100644 index 0000000000..1aa2934ef9 --- /dev/null +++ b/src/common/error-reporting/initialize-sentry-reporting.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import initializeSentryReportingWithInjectable from "./initialize-sentry-reporting.injectable"; + +export default getGlobalOverride(initializeSentryReportingWithInjectable, () => () => {}); diff --git a/src/common/error-reporting/initialize-sentry-reporting.injectable.ts b/src/common/error-reporting/initialize-sentry-reporting.injectable.ts new file mode 100644 index 0000000000..778f959739 --- /dev/null +++ b/src/common/error-reporting/initialize-sentry-reporting.injectable.ts @@ -0,0 +1,63 @@ +/** + * 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 type { ElectronMainOptions } from "@sentry/electron/main"; +import type { BrowserOptions } from "@sentry/electron/renderer"; +import isProductionInjectable from "../vars/is-production.injectable"; +import sentryDataSourceNameInjectable from "../vars/sentry-dsn-url.injectable"; +import { Dedupe, Offline } from "@sentry/integrations"; +import { inspect } from "util"; +import userStoreInjectable from "../user-store/user-store.injectable"; + +export type InitializeSentryReportingWith = (initSentry: (opts: BrowserOptions | ElectronMainOptions) => void) => void; + +const mapProcessName = (type: "browser" | "renderer" | "worker") => type === "browser" ? "main" : type; + +const initializeSentryReportingWithInjectable = getInjectable({ + id: "initialize-sentry-reporting-with", + instantiate: (di): InitializeSentryReportingWith => { + const sentryDataSourceName = di.inject(sentryDataSourceNameInjectable); + const isProduction = di.inject(isProductionInjectable); + const userStore = di.inject(userStoreInjectable); + + if (!sentryDataSourceName) { + return () => {}; + } + + return (initSentry) => initSentry({ + beforeSend: (event) => { + if (userStore.allowErrorReporting) { + return event; + } + + /** + * Directly write to stdout so that no other integrations capture this and create an infinite loop + */ + process.stdout.write(`🔒 [SENTRY-BEFORE-SEND-HOOK]: Sentry event is caught but not sent to server.`); + process.stdout.write("🔒 [SENTRY-BEFORE-SEND-HOOK]: === START OF SENTRY EVENT ==="); + process.stdout.write(inspect(event, false, null, true)); + process.stdout.write("🔒 [SENTRY-BEFORE-SEND-HOOK]: === END OF SENTRY EVENT ==="); + + // if return null, the event won't be sent + // ref https://github.com/getsentry/sentry-javascript/issues/2039 + return null; + }, + dsn: sentryDataSourceName, + integrations: [ + new Dedupe(), + new Offline(), + ], + initialScope: { + tags: { + "process": mapProcessName(process.type), + }, + }, + environment: isProduction ? "production" : "development", + }); + }, + causesSideEffects: true, +}); + +export default initializeSentryReportingWithInjectable; diff --git a/src/common/event-emitter.ts b/src/common/event-emitter.ts index 4d7f5ca53c..03f8e2754b 100644 --- a/src/common/event-emitter.ts +++ b/src/common/event-emitter.ts @@ -29,7 +29,7 @@ export class EventEmitter { this.listeners.length = 0; } - emit = (...data: D) => { + emit(...data: D) { for (const [callback, { once }] of this.listeners) { if (once) { this.removeListener(callback); @@ -39,5 +39,5 @@ export class EventEmitter { break; } } - }; + } } diff --git a/src/common/fetch/download-binary.injectable.ts b/src/common/fetch/download-binary.injectable.ts new file mode 100644 index 0000000000..27ef43d59b --- /dev/null +++ b/src/common/fetch/download-binary.injectable.ts @@ -0,0 +1,56 @@ +/** + * 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 type { RequestInit, Response } from "node-fetch"; +import type { AsyncResult } from "../utils/async-result"; +import fetchInjectable from "./fetch.injectable"; + +export interface DownloadBinaryOptions { + signal?: AbortSignal | null | undefined; +} + +export type DownloadBinary = (url: string, opts?: DownloadBinaryOptions) => Promise>; + +const downloadBinaryInjectable = getInjectable({ + id: "download-binary", + instantiate: (di): DownloadBinary => { + const fetch = di.inject(fetchInjectable); + + return async (url, opts) => { + let result: Response; + + try { + // TODO: upgrade node-fetch once we switch to ESM + result = await fetch(url, opts as RequestInit); + } catch (error) { + return { + callWasSuccessful: false, + error: String(error), + }; + } + + if (result.status < 200 || 300 <= result.status) { + return { + callWasSuccessful: false, + error: result.statusText, + }; + } + + try { + return { + callWasSuccessful: true, + response: await result.buffer(), + }; + } catch (error) { + return { + callWasSuccessful: false, + error: String(error), + }; + } + }; + }, +}); + +export default downloadBinaryInjectable; diff --git a/src/common/fetch/download-json.injectable.ts b/src/common/fetch/download-json.injectable.ts new file mode 100644 index 0000000000..78a7d030d7 --- /dev/null +++ b/src/common/fetch/download-json.injectable.ts @@ -0,0 +1,55 @@ +/** + * 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 type { RequestInit, Response } from "node-fetch"; +import type { AsyncResult } from "../utils/async-result"; +import fetchInjectable from "./fetch.injectable"; + +export interface DownloadJsonOptions { + signal?: AbortSignal | null | undefined; +} + +export type DownloadJson = (url: string, opts?: DownloadJsonOptions) => Promise>; + +const downloadJsonInjectable = getInjectable({ + id: "download-json", + instantiate: (di): DownloadJson => { + const fetch = di.inject(fetchInjectable); + + return async (url, opts) => { + let result: Response; + + try { + result = await fetch(url, opts as RequestInit); + } catch (error) { + return { + callWasSuccessful: false, + error: String(error), + }; + } + + if (result.status < 200 || 300 <= result.status) { + return { + callWasSuccessful: false, + error: result.statusText, + }; + } + + try { + return { + callWasSuccessful: true, + response: await result.json(), + }; + } catch (error) { + return { + callWasSuccessful: false, + error: String(error), + }; + } + }; + }, +}); + +export default downloadJsonInjectable; diff --git a/src/common/fetch/fetch.global-override-for-injectable.ts b/src/common/fetch/fetch.global-override-for-injectable.ts new file mode 100644 index 0000000000..1a5f80735c --- /dev/null +++ b/src/common/fetch/fetch.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverrideForFunction } from "../test-utils/get-global-override-for-function"; +import fetchInjectable from "./fetch.injectable"; + +export default getGlobalOverrideForFunction(fetchInjectable); diff --git a/src/common/fetch/fetch.injectable.ts b/src/common/fetch/fetch.injectable.ts new file mode 100644 index 0000000000..e320c0128a --- /dev/null +++ b/src/common/fetch/fetch.injectable.ts @@ -0,0 +1,21 @@ +/** + * 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 type * as FetchModule from "node-fetch"; + +const { NodeFetch: { default: fetch }} = require("../../../build/webpack/node-fetch.bundle") as { NodeFetch: typeof FetchModule }; + +type Response = FetchModule.Response; +type RequestInit = FetchModule.RequestInit; + +export type Fetch = (url: string, init?: RequestInit) => Promise; + +const fetchInjectable = getInjectable({ + id: "fetch", + instantiate: (): Fetch => fetch, + causesSideEffects: true, +}); + +export default fetchInjectable; diff --git a/src/common/fetch/timeout-controller.ts b/src/common/fetch/timeout-controller.ts new file mode 100644 index 0000000000..702becdc9d --- /dev/null +++ b/src/common/fetch/timeout-controller.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * Creates an AbortController with an associated timeout + * @param timeout The number of milliseconds before this controller will auto abort + */ +export function withTimeout(timeout: number): AbortController { + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeout); + + controller.signal.addEventListener("abort", () => clearTimeout(id)); + + return controller; +} diff --git a/src/common/front-end-routing/app-navigation-channel.injectable.ts b/src/common/front-end-routing/app-navigation-channel.injectable.ts deleted file mode 100644 index 869fbfdecd..0000000000 --- a/src/common/front-end-routing/app-navigation-channel.injectable.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * 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 { IpcRendererNavigationEvents } from "../../renderer/navigation/events"; -import type { MessageChannel } from "../utils/channel/message-channel-injection-token"; -import { messageChannelInjectionToken } from "../utils/channel/message-channel-injection-token"; - -export type AppNavigationChannel = MessageChannel; - -const appNavigationChannelInjectable = getInjectable({ - id: "app-navigation-channel", - - instantiate: (): AppNavigationChannel => ({ - id: IpcRendererNavigationEvents.NAVIGATE_IN_APP, - }), - - injectionToken: messageChannelInjectionToken, -}); - -export default appNavigationChannelInjectable; diff --git a/src/common/front-end-routing/app-navigation-channel.ts b/src/common/front-end-routing/app-navigation-channel.ts new file mode 100644 index 0000000000..e0b881eff9 --- /dev/null +++ b/src/common/front-end-routing/app-navigation-channel.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { IpcRendererNavigationEvents } from "../../renderer/navigation/events"; +import type { MessageChannel } from "../utils/channel/message-channel-listener-injection-token"; + +export type AppNavigationChannel = MessageChannel; + +export const appNavigationChannel: AppNavigationChannel = { + id: IpcRendererNavigationEvents.NAVIGATE_IN_APP, +}; diff --git a/src/common/front-end-routing/cluster-frame-navigation-channel.injectable.ts b/src/common/front-end-routing/cluster-frame-navigation-channel.injectable.ts deleted file mode 100644 index 596bd6d351..0000000000 --- a/src/common/front-end-routing/cluster-frame-navigation-channel.injectable.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * 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 { IpcRendererNavigationEvents } from "../../renderer/navigation/events"; -import type { MessageChannel } from "../utils/channel/message-channel-injection-token"; -import { messageChannelInjectionToken } from "../utils/channel/message-channel-injection-token"; - -export type ClusterFrameNavigationChannel = MessageChannel; - -const clusterFrameNavigationChannelInjectable = getInjectable({ - id: "cluster-frame-navigation-channel", - - instantiate: (): ClusterFrameNavigationChannel => ({ - id: IpcRendererNavigationEvents.NAVIGATE_IN_CLUSTER, - }), - - injectionToken: messageChannelInjectionToken, -}); - -export default clusterFrameNavigationChannelInjectable; diff --git a/src/common/front-end-routing/cluster-frame-navigation-channel.ts b/src/common/front-end-routing/cluster-frame-navigation-channel.ts new file mode 100644 index 0000000000..9d9a1904af --- /dev/null +++ b/src/common/front-end-routing/cluster-frame-navigation-channel.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { IpcRendererNavigationEvents } from "../../renderer/navigation/events"; +import type { MessageChannel } from "../utils/channel/message-channel-listener-injection-token"; + +export type ClusterFrameNavigationChannel = MessageChannel; + +export const clusterFrameNavigationChannel: ClusterFrameNavigationChannel = { + id: IpcRendererNavigationEvents.NAVIGATE_IN_CLUSTER, +}; diff --git a/src/common/front-end-routing/routes/cluster/config/leases/leases-route.injectable.ts b/src/common/front-end-routing/routes/cluster/config/leases/leases-route.injectable.ts new file mode 100644 index 0000000000..65ee0e3ffa --- /dev/null +++ b/src/common/front-end-routing/routes/cluster/config/leases/leases-route.injectable.ts @@ -0,0 +1,25 @@ +/** + * 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 isAllowedResourceInjectable from "../../../../../utils/is-allowed-resource.injectable"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const leasesRouteInjectable = getInjectable({ + id: "leases", + + instantiate: (di) => { + const isAllowedResource = di.inject(isAllowedResourceInjectable, "leases"); + + return { + path: "/leases", + clusterFrame: true, + isEnabled: isAllowedResource, + }; + }, + + injectionToken: frontEndRouteInjectionToken, +}); + +export default leasesRouteInjectable; diff --git a/src/common/front-end-routing/routes/cluster/config/leases/navigate-to-leases.injectable.ts b/src/common/front-end-routing/routes/cluster/config/leases/navigate-to-leases.injectable.ts new file mode 100644 index 0000000000..5bf7d74ff1 --- /dev/null +++ b/src/common/front-end-routing/routes/cluster/config/leases/navigate-to-leases.injectable.ts @@ -0,0 +1,20 @@ +/** + * 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 leasesRouteInjectable from "./leases-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToLeasesInjectable = getInjectable({ + id: "navigate-to-leases", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(leasesRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToLeasesInjectable; diff --git a/src/common/front-end-routing/routes/preferences/app/navigate-to-app-preferences.injectable.ts b/src/common/front-end-routing/routes/cluster/config/runtime-classes/navigate-to-runtime-classes.injectable.ts similarity index 50% rename from src/common/front-end-routing/routes/preferences/app/navigate-to-app-preferences.injectable.ts rename to src/common/front-end-routing/routes/cluster/config/runtime-classes/navigate-to-runtime-classes.injectable.ts index 4f83d47dec..4d6ca1757d 100644 --- a/src/common/front-end-routing/routes/preferences/app/navigate-to-app-preferences.injectable.ts +++ b/src/common/front-end-routing/routes/cluster/config/runtime-classes/navigate-to-runtime-classes.injectable.ts @@ -3,18 +3,18 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import appPreferencesRouteInjectable from "./app-preferences-route.injectable"; -import { navigateToRouteInjectionToken } from "../../../navigate-to-route-injection-token"; +import runtimeClassesRouteInjectable from "./runtime-classes-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; -const navigateToAppPreferencesInjectable = getInjectable({ - id: "navigate-to-app-preferences", +const navigateToRuntimeClassesInjectable = getInjectable({ + id: "navigate-to-runtime-classes", instantiate: (di) => { const navigateToRoute = di.inject(navigateToRouteInjectionToken); - const route = di.inject(appPreferencesRouteInjectable); + const route = di.inject(runtimeClassesRouteInjectable); return () => navigateToRoute(route); }, }); -export default navigateToAppPreferencesInjectable; +export default navigateToRuntimeClassesInjectable; diff --git a/src/common/front-end-routing/routes/cluster/config/runtime-classes/runtime-classes-route.injectable.ts b/src/common/front-end-routing/routes/cluster/config/runtime-classes/runtime-classes-route.injectable.ts new file mode 100644 index 0000000000..72934f9158 --- /dev/null +++ b/src/common/front-end-routing/routes/cluster/config/runtime-classes/runtime-classes-route.injectable.ts @@ -0,0 +1,25 @@ +/** + * 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 isAllowedResourceInjectable from "../../../../../utils/is-allowed-resource.injectable"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const runtimeClassesRouteInjectable = getInjectable({ + id: "runtime-classes-route", + + instantiate: (di) => { + const isAllowedResource = di.inject(isAllowedResourceInjectable, "runtimeclasses"); + + return { + path: "/runtimeclasses", + clusterFrame: true, + isEnabled: isAllowedResource, + }; + }, + + injectionToken: frontEndRouteInjectionToken, +}); + +export default runtimeClassesRouteInjectable; diff --git a/src/common/front-end-routing/routes/preferences/editor/editor-preferences-route.injectable.ts b/src/common/front-end-routing/routes/preferences/editor/editor-preferences-route.injectable.ts deleted file mode 100644 index ca6cbf8a70..0000000000 --- a/src/common/front-end-routing/routes/preferences/editor/editor-preferences-route.injectable.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * 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 { computed } from "mobx"; -import { frontEndRouteInjectionToken } from "../../../front-end-route-injection-token"; - -const editorPreferencesRouteInjectable = getInjectable({ - id: "editor-preferences-route", - - instantiate: () => ({ - path: "/preferences/editor", - clusterFrame: false, - isEnabled: computed(() => true), - }), - - injectionToken: frontEndRouteInjectionToken, -}); - -export default editorPreferencesRouteInjectable; diff --git a/src/common/front-end-routing/routes/preferences/extension/extension-preferences-route.injectable.ts b/src/common/front-end-routing/routes/preferences/extension/extension-preferences-route.injectable.ts deleted file mode 100644 index 38d25b4114..0000000000 --- a/src/common/front-end-routing/routes/preferences/extension/extension-preferences-route.injectable.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * 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 { computed } from "mobx"; -import { frontEndRouteInjectionToken } from "../../../front-end-route-injection-token"; -import type { Route } from "../../../front-end-route-injection-token"; - -interface ExtensionPreferenceRouteParams { - extensionId: string; - tabId?: string; -} - -const extensionPreferencesRouteInjectable = getInjectable({ - id: "extension-preferences-route", - - instantiate: (): Route => ({ - path: "/preferences/extension/:extensionId/:tabId?", - clusterFrame: false, - isEnabled: computed(() => true), - }), - - injectionToken: frontEndRouteInjectionToken, -}); - -export default extensionPreferencesRouteInjectable; diff --git a/src/common/front-end-routing/routes/preferences/extension/navigate-to-extension-preferences.injectable.ts b/src/common/front-end-routing/routes/preferences/extension/navigate-to-extension-preferences.injectable.ts deleted file mode 100644 index 085694a578..0000000000 --- a/src/common/front-end-routing/routes/preferences/extension/navigate-to-extension-preferences.injectable.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * 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 extensionPreferencesRouteInjectable from "./extension-preferences-route.injectable"; -import { navigateToRouteInjectionToken } from "../../../navigate-to-route-injection-token"; - -const navigateToExtensionPreferencesInjectable = getInjectable({ - id: "navigate-to-extension-preferences", - - instantiate: (di) => { - const navigateToRoute = di.inject(navigateToRouteInjectionToken); - const route = di.inject(extensionPreferencesRouteInjectable); - - return (extensionId: string, tabId?: string) => navigateToRoute(route, { - parameters: { - extensionId, - tabId, - }, - withoutAffectingBackButton: true, - }); - }, -}); - -export default navigateToExtensionPreferencesInjectable; diff --git a/src/common/front-end-routing/routes/preferences/kubernetes/kubernetes-preferences-route.injectable.ts b/src/common/front-end-routing/routes/preferences/kubernetes/kubernetes-preferences-route.injectable.ts deleted file mode 100644 index 7b3479c7fa..0000000000 --- a/src/common/front-end-routing/routes/preferences/kubernetes/kubernetes-preferences-route.injectable.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * 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 { computed } from "mobx"; -import { frontEndRouteInjectionToken } from "../../../front-end-route-injection-token"; - -const kubernetesPreferencesRouteInjectable = getInjectable({ - id: "kubernetes-preferences-route", - - instantiate: () => ({ - path: "/preferences/kubernetes", - clusterFrame: false, - isEnabled: computed(() => true), - }), - - injectionToken: frontEndRouteInjectionToken, -}); - -export default kubernetesPreferencesRouteInjectable; diff --git a/src/common/front-end-routing/routes/preferences/navigate-to-preferences.injectable.ts b/src/common/front-end-routing/routes/preferences/navigate-to-preferences.injectable.ts deleted file mode 100644 index 545751d7c3..0000000000 --- a/src/common/front-end-routing/routes/preferences/navigate-to-preferences.injectable.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * 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 navigateToAppPreferencesInjectable from "./app/navigate-to-app-preferences.injectable"; - -const navigateToPreferencesInjectable = getInjectable({ - id: "navigate-to-preferences", - - instantiate: (di) => di.inject(navigateToAppPreferencesInjectable), -}); - -export default navigateToPreferencesInjectable; diff --git a/src/common/front-end-routing/routes/preferences/telemetry/telemetry-preferences-route.injectable.ts b/src/common/front-end-routing/routes/preferences/telemetry/telemetry-preferences-route.injectable.ts deleted file mode 100644 index 2ae9f14acd..0000000000 --- a/src/common/front-end-routing/routes/preferences/telemetry/telemetry-preferences-route.injectable.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * 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 { computed } from "mobx"; -import { frontEndRouteInjectionToken } from "../../../front-end-route-injection-token"; - -const telemetryPreferencesRouteInjectable = getInjectable({ - id: "telemetry-preferences-route", - - instantiate: () => ({ - path: "/preferences/telemetry", - clusterFrame: false, - isEnabled: computed(() => true), - }), - - injectionToken: frontEndRouteInjectionToken, -}); - -export default telemetryPreferencesRouteInjectable; diff --git a/src/common/front-end-routing/routes/preferences/terminal/terminal-preferences-route.injectable.ts b/src/common/front-end-routing/routes/preferences/terminal/terminal-preferences-route.injectable.ts deleted file mode 100644 index c077c45bab..0000000000 --- a/src/common/front-end-routing/routes/preferences/terminal/terminal-preferences-route.injectable.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * 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 { computed } from "mobx"; -import { frontEndRouteInjectionToken } from "../../../front-end-route-injection-token"; - -const terminalPreferencesRouteInjectable = getInjectable({ - id: "terminal-preferences-route", - - instantiate: () => ({ - path: "/preferences/terminal", - clusterFrame: false, - isEnabled: computed(() => true), - }), - - injectionToken: frontEndRouteInjectionToken, -}); - -export default terminalPreferencesRouteInjectable; diff --git a/src/common/front-end-routing/routes/welcome/default-welcome-route.injectable.ts b/src/common/front-end-routing/routes/welcome/default-welcome-route.injectable.ts new file mode 100644 index 0000000000..d8db889033 --- /dev/null +++ b/src/common/front-end-routing/routes/welcome/default-welcome-route.injectable.ts @@ -0,0 +1,26 @@ +/** + * 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 { computed } from "mobx"; +import welcomeRouteConfigInjectable from "./welcome-route-config.injectable"; +import { frontEndRouteInjectionToken } from "../../front-end-route-injection-token"; + +const defaultWelcomeRouteInjectable = getInjectable({ + id: "default-welcome-route", + + instantiate: (di) => { + const welcomeRoute = di.inject(welcomeRouteConfigInjectable); + + return { + path: "/welcome", + clusterFrame: false, + isEnabled: computed(() => welcomeRoute === "/welcome"), + }; + }, + + injectionToken: frontEndRouteInjectionToken, +}); + +export default defaultWelcomeRouteInjectable; diff --git a/src/common/front-end-routing/routes/welcome/welcome-route-config.injectable.ts b/src/common/front-end-routing/routes/welcome/welcome-route-config.injectable.ts new file mode 100644 index 0000000000..4e16df5bb4 --- /dev/null +++ b/src/common/front-end-routing/routes/welcome/welcome-route-config.injectable.ts @@ -0,0 +1,14 @@ +/** + * 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 applicationInformationInjectable from "../../../vars/application-information.injectable"; + +const welcomeRouteConfigInjectable = getInjectable({ + id: "welcome-route-config", + + instantiate: (di) => di.inject(applicationInformationInjectable).config.welcomeRoute, +}); + +export default welcomeRouteConfigInjectable; diff --git a/src/common/front-end-routing/routes/welcome/welcome-route.injectable.ts b/src/common/front-end-routing/routes/welcome/welcome-route.injectable.ts index 75d722ab46..839a7446c1 100644 --- a/src/common/front-end-routing/routes/welcome/welcome-route.injectable.ts +++ b/src/common/front-end-routing/routes/welcome/welcome-route.injectable.ts @@ -4,16 +4,21 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { computed } from "mobx"; +import welcomeRouteConfigInjectable from "./welcome-route-config.injectable"; import { frontEndRouteInjectionToken } from "../../front-end-route-injection-token"; const welcomeRouteInjectable = getInjectable({ id: "welcome-route", - instantiate: () => ({ - path: "/welcome", - clusterFrame: false, - isEnabled: computed(() => true), - }), + instantiate: (di) => { + const welcomeRoute = di.inject(welcomeRouteConfigInjectable); + + return { + path: welcomeRoute, + clusterFrame: false, + isEnabled: computed(() => true), + }; + }, injectionToken: frontEndRouteInjectionToken, }); diff --git a/src/common/front-end-routing/verify-that-all-routes-have-route-component.test.ts b/src/common/front-end-routing/verify-that-all-routes-have-route-component.test.ts index 4b6776bdaa..acd1d4401d 100644 --- a/src/common/front-end-routing/verify-that-all-routes-have-route-component.test.ts +++ b/src/common/front-end-routing/verify-that-all-routes-have-route-component.test.ts @@ -5,7 +5,7 @@ import { getDiForUnitTesting } from "../../renderer/getDiForUnitTesting"; import { routeSpecificComponentInjectionToken } from "../../renderer/routes/route-specific-component-injection-token"; import { frontEndRouteInjectionToken } from "./front-end-route-injection-token"; -import { filter, map, matches } from "lodash/fp"; +import { filter, map } from "lodash/fp"; import clusterStoreInjectable from "../cluster-store/cluster-store.injectable"; import type { ClusterStore } from "../cluster-store/cluster-store"; import { pipeline } from "@ogre-tools/fp"; @@ -27,9 +27,11 @@ describe("verify-that-all-routes-have-component", () => { routes, map( - (route) => ({ - path: route.path, - routeComponent: routeComponents.find(matches({ route })), + (currentRoute) => ({ + path: currentRoute.path, + routeComponent: routeComponents.find(({ route }) => ( + route.path === currentRoute.path + && route.clusterFrame === currentRoute.clusterFrame)), }), ), diff --git a/src/common/fs/access-path.global-override-for-injectable.ts b/src/common/fs/access-path.global-override-for-injectable.ts new file mode 100644 index 0000000000..747d839682 --- /dev/null +++ b/src/common/fs/access-path.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import accessPathInjectable from "./access-path.injectable"; + +export default getGlobalOverride(accessPathInjectable, () => async () => { + throw new Error("tried to verify path access without override"); +}); diff --git a/src/common/fs/access-path.injectable.ts b/src/common/fs/access-path.injectable.ts new file mode 100644 index 0000000000..0504de9d6f --- /dev/null +++ b/src/common/fs/access-path.injectable.ts @@ -0,0 +1,27 @@ +/** + * 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 fsInjectable from "./fs.injectable"; + +export type AccessPath = (path: string, mode?: number) => Promise; + +const accessPathInjectable = getInjectable({ + id: "access-path", + instantiate: (di): AccessPath => { + const { access } = di.inject(fsInjectable); + + return async (path, mode) => { + try { + await access(path, mode); + + return true; + } catch { + return false; + } + }; + }, +}); + +export default accessPathInjectable; diff --git a/src/common/fs/copy.global-override-for-injectable.ts b/src/common/fs/copy.global-override-for-injectable.ts new file mode 100644 index 0000000000..b6d899d2c4 --- /dev/null +++ b/src/common/fs/copy.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import copyInjectable from "./copy.injectable"; + +export default getGlobalOverride(copyInjectable, () => async () => { + throw new Error("tried to copy filepaths without override"); +}); diff --git a/src/common/fs/copy.injectable.ts b/src/common/fs/copy.injectable.ts new file mode 100644 index 0000000000..6a64ee3751 --- /dev/null +++ b/src/common/fs/copy.injectable.ts @@ -0,0 +1,16 @@ +/** + * 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 type { CopyOptions } from "fs-extra"; +import fsInjectable from "./fs.injectable"; + +export type Copy = (src: string, dest: string, options?: CopyOptions | undefined) => Promise; + +const copyInjectable = getInjectable({ + id: "copy", + instantiate: (di): Copy => di.inject(fsInjectable).copy, +}); + +export default copyInjectable; diff --git a/src/common/fs/create-read-file-stream.injectable.ts b/src/common/fs/create-read-file-stream.injectable.ts new file mode 100644 index 0000000000..8714e1cdcd --- /dev/null +++ b/src/common/fs/create-read-file-stream.injectable.ts @@ -0,0 +1,30 @@ +/** + * 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 type { ReadStream } from "fs"; +import fsInjectable from "./fs.injectable"; + +export interface CreateReadStreamOptions { + mode?: number; + end?: number | undefined; + flags?: string | undefined; + encoding?: BufferEncoding | undefined; + autoClose?: boolean | undefined; + /** + * @default false + */ + emitClose?: boolean | undefined; + start?: number | undefined; + highWaterMark?: number | undefined; +} + +export type CreateReadFileStream = (filePath: string, options?: CreateReadStreamOptions) => ReadStream; + +const createReadFileStreamInjectable = getInjectable({ + id: "create-read-file-stream", + instantiate: (di): CreateReadFileStream => di.inject(fsInjectable).createReadStream, +}); + +export default createReadFileStreamInjectable; diff --git a/src/common/fs/delete-file.global-override-for-injectable.ts b/src/common/fs/delete-file.global-override-for-injectable.ts new file mode 100644 index 0000000000..c03dca88dc --- /dev/null +++ b/src/common/fs/delete-file.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import deleteFileInjectable from "./delete-file.injectable"; + +export default getGlobalOverride(deleteFileInjectable, () => async () => { + throw new Error("tried to delete file without override"); +}); diff --git a/src/common/fs/delete-file.injectable.ts b/src/common/fs/delete-file.injectable.ts new file mode 100644 index 0000000000..57aba5b379 --- /dev/null +++ b/src/common/fs/delete-file.injectable.ts @@ -0,0 +1,15 @@ +/** + * 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 fsInjectable from "./fs.injectable"; + +export type DeleteFile = (filePath: string) => Promise; + +const deleteFileInjectable = getInjectable({ + id: "delete-file", + instantiate: (di): DeleteFile => di.inject(fsInjectable).unlink, +}); + +export default deleteFileInjectable; diff --git a/src/common/fs/ensure-dir.global-override-for-injectable.ts b/src/common/fs/ensure-dir.global-override-for-injectable.ts new file mode 100644 index 0000000000..4dd098b163 --- /dev/null +++ b/src/common/fs/ensure-dir.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import ensureDirInjectable from "./ensure-dir.injectable"; + +export default getGlobalOverride(ensureDirInjectable, () => async () => { + throw new Error("tried to ensure directory without override"); +}); diff --git a/src/common/fs/ensure-dir.injectable.ts b/src/common/fs/ensure-dir.injectable.ts index 88410ceee2..78ec4d91dc 100644 --- a/src/common/fs/ensure-dir.injectable.ts +++ b/src/common/fs/ensure-dir.injectable.ts @@ -5,14 +5,14 @@ import { getInjectable } from "@ogre-tools/injectable"; import fsInjectable from "./fs.injectable"; +export type EnsureDirectory = (dirPath: string) => Promise; + const ensureDirInjectable = getInjectable({ id: "ensure-dir", // TODO: Remove usages of ensureDir from business logic. // TODO: Read, Write, Watch etc. operations should do this internally. - instantiate: (di) => di.inject(fsInjectable).ensureDir, - - causesSideEffects: true, + instantiate: (di): EnsureDirectory => di.inject(fsInjectable).ensureDir, }); export default ensureDirInjectable; diff --git a/src/common/fs/exec-file.injectable.ts b/src/common/fs/exec-file.injectable.ts index 15d0ad48dc..f026e0db3a 100644 --- a/src/common/fs/exec-file.injectable.ts +++ b/src/common/fs/exec-file.injectable.ts @@ -3,22 +3,52 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import type { ExecFileOptions } from "child_process"; +import type { ExecFileException, ExecFileOptions } from "child_process"; import { execFile } from "child_process"; -import { promisify } from "util"; +import type { AsyncResult } from "../utils/async-result"; -export type ExecFile = (filePath: string, args: string[], options: ExecFileOptions) => Promise; +export type ExecFileError = ExecFileException & { stderr: string }; + +export interface ExecFile { + (filePath: string): Promise>; + (filePath: string, argsOrOptions: string[] | ExecFileOptions): Promise>; + (filePath: string, args: string[], options: ExecFileOptions): Promise>; +} const execFileInjectable = getInjectable({ id: "exec-file", instantiate: (): ExecFile => { - const asyncExecFile = promisify(execFile); + return (filePath: string, argsOrOptions?: string[] | ExecFileOptions, maybeOptions?: ExecFileOptions) => { + const { args, options } = (() => { + if (Array.isArray(argsOrOptions)) { + return { + args: argsOrOptions, + options: maybeOptions ?? {}, + }; + } else { + return { + args: [], + options: argsOrOptions ?? {}, + }; + } + })(); - return async (filePath, args, options) => { - const result = await asyncExecFile(filePath, args, options); - - return result.stdout; + return new Promise((resolve) => { + execFile(filePath, args, options, (error, stdout, stderr) => { + if (error) { + resolve({ + callWasSuccessful: false, + error: Object.assign(error, { stderr }), + }); + } else { + resolve({ + callWasSuccessful: true, + response: stdout, + }); + } + }); + }); }; }, diff --git a/src/common/fs/extract-tar.global-override-for-injectable.ts b/src/common/fs/extract-tar.global-override-for-injectable.ts new file mode 100644 index 0000000000..02a46c1d6b --- /dev/null +++ b/src/common/fs/extract-tar.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import extractTarInjectable from "./extract-tar.injectable"; + +export default getGlobalOverride(extractTarInjectable, () => async () => { + throw new Error("tried to extract a tar file without override"); +}); diff --git a/src/common/fs/extract-tar.injectable.ts b/src/common/fs/extract-tar.injectable.ts new file mode 100644 index 0000000000..410512139e --- /dev/null +++ b/src/common/fs/extract-tar.injectable.ts @@ -0,0 +1,26 @@ +/** + * 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 type { ExtractOptions } from "tar"; +import { extract } from "tar"; +import getDirnameOfPathInjectable from "../path/get-dirname.injectable"; + +export type ExtractTar = (filePath: string, opts?: ExtractOptions) => Promise; + +const extractTarInjectable = getInjectable({ + id: "extract-tar", + instantiate: (di): ExtractTar => { + const getDirnameOfPath = di.inject(getDirnameOfPathInjectable); + + return (filePath, opts = {}) => extract({ + file: filePath, + cwd: getDirnameOfPath(filePath), + ...opts, + }); + }, + causesSideEffects: true, +}); + +export default extractTarInjectable; diff --git a/src/common/fs/lstat.global-override-for-injectable.ts b/src/common/fs/lstat.global-override-for-injectable.ts new file mode 100644 index 0000000000..9c9f3d4933 --- /dev/null +++ b/src/common/fs/lstat.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import lstatInjectable from "./lstat.injectable"; + +export default getGlobalOverride(lstatInjectable, () => async () => { + throw new Error("tried to lstat a filepath without override"); +}); diff --git a/src/common/fs/lstat.injectable.ts b/src/common/fs/lstat.injectable.ts new file mode 100644 index 0000000000..50c1d4ad12 --- /dev/null +++ b/src/common/fs/lstat.injectable.ts @@ -0,0 +1,16 @@ +/** + * 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 type { Stats } from "fs"; +import fsInjectable from "./fs.injectable"; + +export type LStat = (path: string) => Promise; + +const lstatInjectable = getInjectable({ + id: "lstat", + instantiate: (di): LStat => di.inject(fsInjectable).lstat, +}); + +export default lstatInjectable; diff --git a/src/common/fs/move.global-override-for-injectable.ts b/src/common/fs/move.global-override-for-injectable.ts new file mode 100644 index 0000000000..c39907ee6e --- /dev/null +++ b/src/common/fs/move.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import moveInjectable from "./move.injectable"; + +export default getGlobalOverride(moveInjectable, () => async () => { + throw new Error("tried to move without override"); +}); diff --git a/src/common/fs/move.injectable.ts b/src/common/fs/move.injectable.ts new file mode 100644 index 0000000000..ff11120d80 --- /dev/null +++ b/src/common/fs/move.injectable.ts @@ -0,0 +1,16 @@ +/** + * 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 type { MoveOptions } from "fs-extra"; +import fsInjectable from "./fs.injectable"; + +export type Move = (src: string, dest: string, options?: MoveOptions) => Promise; + +const moveInjectable = getInjectable({ + id: "move", + instantiate: (di): Move => di.inject(fsInjectable).move, +}); + +export default moveInjectable; diff --git a/src/common/fs/path-exists.global-override-for-injectable.ts b/src/common/fs/path-exists.global-override-for-injectable.ts new file mode 100644 index 0000000000..1b9b96c8dd --- /dev/null +++ b/src/common/fs/path-exists.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import pathExistsInjectable from "./path-exists.injectable"; + +export default getGlobalOverride(pathExistsInjectable, () => async () => { + throw new Error("Tried to check if a path exists without override"); +}); diff --git a/src/common/fs/read-directory.global-override-for-injectable.ts b/src/common/fs/read-directory.global-override-for-injectable.ts new file mode 100644 index 0000000000..57c83ceffb --- /dev/null +++ b/src/common/fs/read-directory.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import readDirectoryInjectable from "./read-directory.injectable"; + +export default getGlobalOverride(readDirectoryInjectable, () => async () => { + throw new Error("tried to read a directory's content without override"); +}); diff --git a/src/common/fs/read-directory.injectable.ts b/src/common/fs/read-directory.injectable.ts new file mode 100644 index 0000000000..57632bd4d7 --- /dev/null +++ b/src/common/fs/read-directory.injectable.ts @@ -0,0 +1,37 @@ +/** + * 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 type { Dirent } from "fs"; +import fsInjectable from "./fs.injectable"; + +export interface ReadDirectory { + ( + path: string, + options: "buffer" | { encoding: "buffer"; withFileTypes?: false | undefined } + ): Promise; + ( + path: string, + options?: + | { encoding: BufferEncoding | string | null; withFileTypes?: false | undefined } + | BufferEncoding + | string + | null, + ): Promise; + ( + path: string, + options?: { encoding?: BufferEncoding | string | null | undefined; withFileTypes?: false | undefined }, + ): Promise; + ( + path: string, + options: { encoding?: BufferEncoding | string | null | undefined; withFileTypes: true }, + ): Promise; +} + +const readDirectoryInjectable = getInjectable({ + id: "read-directory", + instantiate: (di): ReadDirectory => di.inject(fsInjectable).readdir, +}); + +export default readDirectoryInjectable; diff --git a/src/common/fs/read-file.injectable.ts b/src/common/fs/read-file.injectable.ts index b0a2b7233e..0dc539e1b1 100644 --- a/src/common/fs/read-file.injectable.ts +++ b/src/common/fs/read-file.injectable.ts @@ -5,11 +5,16 @@ import { getInjectable } from "@ogre-tools/injectable"; import fsInjectable from "./fs.injectable"; +export type ReadFile = (filePath: string) => Promise; + const readFileInjectable = getInjectable({ id: "read-file", - instantiate: (di) => (filePath: string) => - di.inject(fsInjectable).readFile(filePath, "utf-8"), + instantiate: (di): ReadFile => { + const { readFile } = di.inject(fsInjectable); + + return (filePath) => readFile(filePath, "utf-8"); + }, }); export default readFileInjectable; diff --git a/src/common/fs/remove-path.global-override-for-injectable.ts b/src/common/fs/remove-path.global-override-for-injectable.ts new file mode 100644 index 0000000000..5b9720a837 --- /dev/null +++ b/src/common/fs/remove-path.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import removePathInjectable from "./remove-path.injectable"; + +export default getGlobalOverride(removePathInjectable, () => async () => { + throw new Error("tried to remove a path without override"); +}); diff --git a/src/common/fs/read-dir.injectable.ts b/src/common/fs/remove-path.injectable.ts similarity index 52% rename from src/common/fs/read-dir.injectable.ts rename to src/common/fs/remove-path.injectable.ts index 2c7b59d9b2..02c8da0e1e 100644 --- a/src/common/fs/read-dir.injectable.ts +++ b/src/common/fs/remove-path.injectable.ts @@ -5,9 +5,11 @@ import { getInjectable } from "@ogre-tools/injectable"; import fsInjectable from "./fs.injectable"; -const readDirInjectable = getInjectable({ - id: "read-dir", - instantiate: (di) => di.inject(fsInjectable).readdir, +export type RemovePath = (path: string) => Promise; + +const removePathInjectable = getInjectable({ + id: "remove-path", + instantiate: (di): RemovePath => di.inject(fsInjectable).remove, }); -export default readDirInjectable; +export default removePathInjectable; diff --git a/src/common/fs/stat/stat.injectable.ts b/src/common/fs/stat/stat.injectable.ts index aa1ce44447..e9924fc088 100644 --- a/src/common/fs/stat/stat.injectable.ts +++ b/src/common/fs/stat/stat.injectable.ts @@ -3,12 +3,14 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; +import type { Stats } from "fs"; import fsInjectable from "../fs.injectable"; +export type Stat = (path: string) => Promise; + const statInjectable = getInjectable({ id: "stat", - - instantiate: (di) => di.inject(fsInjectable).stat, + instantiate: (di): Stat => di.inject(fsInjectable).stat, }); export default statInjectable; diff --git a/src/common/fs/watch/watch.injectable.ts b/src/common/fs/watch/watch.injectable.ts index 44d34f20f5..50f96cdf57 100644 --- a/src/common/fs/watch/watch.injectable.ts +++ b/src/common/fs/watch/watch.injectable.ts @@ -3,15 +3,161 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import type { FSWatcher, WatchOptions } from "chokidar"; import { watch } from "chokidar"; +import type { Stats } from "fs"; +import type TypedEventEmitter from "typed-emitter"; +import type { SingleOrMany } from "../../utils"; -export type Watch = (path: string, options?: WatchOptions) => FSWatcher; +export interface AlwaysStatWatcherEvents { + add: (path: string, stats: Stats) => void; + addDir: (path: string, stats: Stats) => void; + change: (path: string, stats: Stats) => void; +} + +export interface MaybeStatWatcherEvents { + add: (path: string, stats?: Stats) => void; + addDir: (path: string, stats?: Stats) => void; + change: (path: string, stats?: Stats) => void; +} + +export type WatcherEvents = BaseWatcherEvents + & ( + AlwaysStat extends true + ? AlwaysStatWatcherEvents + : MaybeStatWatcherEvents + ); + +export interface BaseWatcherEvents { + error: (error: Error) => void; + ready: () => void; + unlink: (path: string) => void; + unlinkDir: (path: string) => void; +} + +export interface Watcher extends TypedEventEmitter> { + close: () => Promise; +} + +export type WatcherOptions = { + /** + * Indicates whether the process should continue to run as long as files are being watched. If + * set to `false` when using `fsevents` to watch, no more events will be emitted after `ready`, + * even if the process continues to run. + */ + persistent?: boolean; + + /** + * ([anymatch](https://github.com/micromatch/anymatch)-compatible definition) Defines files/paths to + * be ignored. The whole relative or absolute path is tested, not just filename. If a function + * with two arguments is provided, it gets called twice per path - once with a single argument + * (the path), second time with two arguments (the path and the + * [`fs.Stats`](https://nodejs.org/api/fs.html#fs_class_fs_stats) object of that path). + */ + ignored?: SingleOrMany boolean)>; + + /** + * If set to `false` then `add`/`addDir` events are also emitted for matching paths while + * instantiating the watching as chokidar discovers these file paths (before the `ready` event). + */ + ignoreInitial?: boolean; + + /** + * When `false`, only the symlinks themselves will be watched for changes instead of following + * the link references and bubbling events through the link's path. + */ + followSymlinks?: boolean; + + /** + * The base directory from which watch `paths` are to be derived. Paths emitted with events will + * be relative to this. + */ + cwd?: string; + + /** + * If set to true then the strings passed to .watch() and .add() are treated as literal path + * names, even if they look like globs. Default: false. + */ + disableGlobbing?: boolean; + + /** + * Whether to use fs.watchFile (backed by polling), or fs.watch. If polling leads to high CPU + * utilization, consider setting this to `false`. It is typically necessary to **set this to + * `true` to successfully watch files over a network**, and it may be necessary to successfully + * watch files in other non-standard situations. Setting to `true` explicitly on OS X overrides + * the `useFsEvents` default. + */ + usePolling?: boolean; + + /** + * Whether to use the `fsevents` watching interface if available. When set to `true` explicitly + * and `fsevents` is available this supercedes the `usePolling` setting. When set to `false` on + * OS X, `usePolling: true` becomes the default. + */ + useFsEvents?: boolean; + + /** + * If set, limits how many levels of subdirectories will be traversed. + */ + depth?: number; + + /** + * Interval of file system polling. + */ + interval?: number; + + /** + * Interval of file system polling for binary files. ([see list of binary extensions](https://gi + * thub.com/sindresorhus/binary-extensions/blob/master/binary-extensions.json)) + */ + binaryInterval?: number; + + /** + * Indicates whether to watch files that don't have read permissions if possible. If watching + * fails due to `EPERM` or `EACCES` with this set to `true`, the errors will be suppressed + * silently. + */ + ignorePermissionErrors?: boolean; + + /** + * `true` if `useFsEvents` and `usePolling` are `false`). Automatically filters out artifacts + * that occur when using editors that use "atomic writes" instead of writing directly to the + * source file. If a file is re-added within 100 ms of being deleted, Chokidar emits a `change` + * event rather than `unlink` then `add`. If the default of 100 ms does not work well for you, + * you can override it by setting `atomic` to a custom value, in milliseconds. + */ + atomic?: boolean | number; + + /** + * can be set to an object in order to adjust timing params: + */ + awaitWriteFinish?: AwaitWriteFinishOptions | boolean; +} & (AlwaysStat extends true + ? { + alwaysStat: true; + } + : { + alwaysStat?: false; + } +); + +export interface AwaitWriteFinishOptions { + /** + * Amount of time in milliseconds for a file size to remain constant before emitting its event. + */ + stabilityThreshold?: number; + + /** + * File size polling interval. + */ + pollInterval?: number; +} + +export type Watch = (path: string, options?: WatcherOptions) => Watcher; // TODO: Introduce wrapper to allow simpler API const watchInjectable = getInjectable({ id: "watch", - instantiate: (): Watch => watch, + instantiate: () => watch as Watch, causesSideEffects: true, }); diff --git a/src/common/fs/write-file.global-override-for-injectable.ts b/src/common/fs/write-file.global-override-for-injectable.ts new file mode 100644 index 0000000000..c8b7ef8e45 --- /dev/null +++ b/src/common/fs/write-file.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import writeFileInjectable from "./write-file.injectable"; + +export default getGlobalOverride(writeFileInjectable, () => async () => { + throw new Error("tried to write file without override"); +}); diff --git a/src/common/fs/write-file.injectable.ts b/src/common/fs/write-file.injectable.ts index 70dcb76373..faa5285ca1 100644 --- a/src/common/fs/write-file.injectable.ts +++ b/src/common/fs/write-file.injectable.ts @@ -3,20 +3,28 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import path from "path"; +import type { WriteFileOptions } from "fs-extra"; +import getDirnameOfPathInjectable from "../path/get-dirname.injectable"; import fsInjectable from "./fs.injectable"; +export type WriteFile = (filePath: string, content: string | Buffer, opts?: WriteFileOptions) => Promise; + const writeFileInjectable = getInjectable({ id: "write-file", - instantiate: (di) => { + instantiate: (di): WriteFile => { const { writeFile, ensureDir } = di.inject(fsInjectable); + const getDirnameOfPath = di.inject(getDirnameOfPathInjectable); - return async (filePath: string, content: string | Buffer) => { - await ensureDir(path.dirname(filePath), { mode: 0o755 }); + return async (filePath, content, opts) => { + await ensureDir(getDirnameOfPath(filePath), { + mode: 0o755, + ...(opts ?? {}), + }); await writeFile(filePath, content, { encoding: "utf-8", + ...(opts ?? {}), }); }; }, diff --git a/src/common/fs/write-json-file.injectable.ts b/src/common/fs/write-json-file.injectable.ts index 6d05e01a7c..a7079d7f84 100644 --- a/src/common/fs/write-json-file.injectable.ts +++ b/src/common/fs/write-json-file.injectable.ts @@ -3,37 +3,27 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import type { EnsureOptions, WriteOptions } from "fs-extra"; -import path from "path"; import type { JsonValue } from "type-fest"; +import getDirnameOfPathInjectable from "../path/get-dirname.injectable"; import fsInjectable from "./fs.injectable"; export type WriteJson = (filePath: string, contents: JsonValue) => Promise; -interface Dependencies { - writeJson: (file: string, object: any, options?: WriteOptions | BufferEncoding | string) => Promise; - ensureDir: (dir: string, options?: EnsureOptions | number) => Promise; -} - -const writeJsonFile = ({ writeJson, ensureDir }: Dependencies): WriteJson => async (filePath, content) => { - await ensureDir(path.dirname(filePath), { mode: 0o755 }); - - await writeJson(filePath, content, { - encoding: "utf-8", - spaces: 2, - }); -}; - const writeJsonFileInjectable = getInjectable({ id: "write-json-file", - instantiate: (di) => { + instantiate: (di): WriteJson => { const { writeJson, ensureDir } = di.inject(fsInjectable); + const getDirnameOfPath = di.inject(getDirnameOfPathInjectable); - return writeJsonFile({ - writeJson, - ensureDir, - }); + return async (filePath, content) => { + await ensureDir(getDirnameOfPath(filePath), { mode: 0o755 }); + + await writeJson(filePath, content, { + encoding: "utf-8", + spaces: 2, + }); + }; }, }); diff --git a/src/common/helm/add-helm-repository-channel.injectable.ts b/src/common/helm/add-helm-repository-channel.injectable.ts deleted file mode 100644 index 0bc10564ad..0000000000 --- a/src/common/helm/add-helm-repository-channel.injectable.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * 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 type { HelmRepo } from "./helm-repo"; -import type { RequestChannel } from "../utils/channel/request-channel-injection-token"; -import { requestChannelInjectionToken } from "../utils/channel/request-channel-injection-token"; -import type { AsyncResult } from "../utils/async-result"; - -export type AddHelmRepositoryChannel = RequestChannel>; - -const addHelmRepositoryChannelInjectable = getInjectable({ - id: "add-helm-repository-channel", - - instantiate: (): AddHelmRepositoryChannel => ({ - id: "add-helm-repository-channel", - }), - - injectionToken: requestChannelInjectionToken, -}); - -export default addHelmRepositoryChannelInjectable; diff --git a/src/common/helm/add-helm-repository-channel.ts b/src/common/helm/add-helm-repository-channel.ts new file mode 100644 index 0000000000..bf5aa19367 --- /dev/null +++ b/src/common/helm/add-helm-repository-channel.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { HelmRepo } from "./helm-repo"; +import type { AsyncResult } from "../utils/async-result"; +import type { RequestChannel } from "../utils/channel/request-channel-listener-injection-token"; + +export type AddHelmRepositoryChannel = RequestChannel>; + +export const addHelmRepositoryChannel: AddHelmRepositoryChannel = { + id: "add-helm-repository-channel", +}; diff --git a/src/common/helm/get-active-helm-repositories-channel.injectable.ts b/src/common/helm/get-active-helm-repositories-channel.injectable.ts deleted file mode 100644 index ac7355ace7..0000000000 --- a/src/common/helm/get-active-helm-repositories-channel.injectable.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * 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 type { RequestChannel } from "../utils/channel/request-channel-injection-token"; -import type { HelmRepo } from "./helm-repo"; -import { requestChannelInjectionToken } from "../utils/channel/request-channel-injection-token"; -import type { AsyncResult } from "../utils/async-result"; - -export type GetHelmRepositoriesChannel = RequestChannel>; - -const getActiveHelmRepositoriesChannelInjectable = getInjectable({ - id: "get-active-helm-repositories-channel", - - instantiate: (): GetHelmRepositoriesChannel => ({ - id: "get-helm-active-list-repositories", - }), - - injectionToken: requestChannelInjectionToken, -}); - -export default getActiveHelmRepositoriesChannelInjectable; diff --git a/src/common/helm/get-active-helm-repositories-channel.ts b/src/common/helm/get-active-helm-repositories-channel.ts new file mode 100644 index 0000000000..26720e9a5f --- /dev/null +++ b/src/common/helm/get-active-helm-repositories-channel.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { HelmRepo } from "./helm-repo"; +import type { AsyncResult } from "../utils/async-result"; +import type { RequestChannel } from "../utils/channel/request-channel-listener-injection-token"; + +export type GetActiveHelmRepositoriesChannel = RequestChannel>; + +export const getActiveHelmRepositoriesChannel: GetActiveHelmRepositoriesChannel = { + id: "get-helm-active-list-repositories", +}; diff --git a/src/common/helm/remove-helm-repository-channel.injectable.ts b/src/common/helm/remove-helm-repository-channel.injectable.ts deleted file mode 100644 index eddecb1ccd..0000000000 --- a/src/common/helm/remove-helm-repository-channel.injectable.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * 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 type { HelmRepo } from "./helm-repo"; -import type { RequestChannel } from "../utils/channel/request-channel-injection-token"; -import { requestChannelInjectionToken } from "../utils/channel/request-channel-injection-token"; - -export type RemoveHelmRepositoryChannel = RequestChannel; - -const removeHelmRepositoryChannelInjectable = getInjectable({ - id: "remove-helm-repository-channel", - - instantiate: (): RemoveHelmRepositoryChannel => ({ - id: "remove-helm-repository-channel", - }), - - injectionToken: requestChannelInjectionToken, -}); - -export default removeHelmRepositoryChannelInjectable; diff --git a/src/common/helm/remove-helm-repository-channel.ts b/src/common/helm/remove-helm-repository-channel.ts new file mode 100644 index 0000000000..4d479d088c --- /dev/null +++ b/src/common/helm/remove-helm-repository-channel.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { AsyncResult } from "../utils/async-result"; +import type { RequestChannel } from "../utils/channel/request-channel-listener-injection-token"; +import type { HelmRepo } from "./helm-repo"; + +export type RemoveHelmRepositoryChannel = RequestChannel>; + +export const removeHelmRepositoryChannel: RemoveHelmRepositoryChannel = { + id: "remove-helm-repository-channel", +}; diff --git a/src/common/initializable-state/create.test.ts b/src/common/initializable-state/create.test.ts new file mode 100644 index 0000000000..38980b0a41 --- /dev/null +++ b/src/common/initializable-state/create.test.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import type { DiContainer, Injectable } from "@ogre-tools/injectable"; +import { runInAction } from "mobx"; +import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; +import type { InitializableState } from "./create"; +import { createInitializableState } from "./create"; + +describe("InitializableState tests", () => { + let di: DiContainer; + + beforeEach(() => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); + }); + + describe("when created", () => { + let stateInjectable: Injectable, unknown, void>; + let initMock: AsyncFnMock<() => number>; + + beforeEach(() => { + initMock = asyncFn(); + stateInjectable = createInitializableState({ + id: "my-state", + init: initMock, + }); + + runInAction(() => { + di.register(stateInjectable); + }); + }); + + describe("when injected", () => { + let state: InitializableState; + + beforeEach(() => { + state = di.inject(stateInjectable); + }); + + it("when get is called, throw", () => { + expect(() => state.get()).toThrowError("InitializableState(my-state) has not been initialized yet"); + }); + + describe("when init is called", () => { + beforeEach(() => { + state.init(); + }); + + it("should call provided initialization function", () => { + expect(initMock).toBeCalled(); + }); + + it("when get is called, throw", () => { + expect(() => state.get()).toThrowError("InitializableState(my-state) has not finished initializing"); + }); + + describe("when initialization resolves", () => { + beforeEach(async () => { + await initMock.resolve(42); + }); + + it("when get is called, returns value", () => { + expect(state.get()).toBe(42); + }); + + it("when init is called again, throws", async () => { + await expect(() => state.init()).rejects.toThrow("Cannot initialize InitializableState(my-state) more than once"); + }); + }); + }); + }); + }); +}); diff --git a/src/common/initializable-state/create.ts b/src/common/initializable-state/create.ts new file mode 100644 index 0000000000..829de57d94 --- /dev/null +++ b/src/common/initializable-state/create.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { DiContainerForInjection, Injectable, InjectionToken } from "@ogre-tools/injectable"; +import { getInjectable } from "@ogre-tools/injectable"; + +export interface CreateInitializableStateArgs { + id: string; + init: (di: DiContainerForInjection) => Promise | T; + injectionToken?: InjectionToken, void>; +} + +export interface InitializableState { + get: () => T; + init: () => Promise; +} + +type InitializableStateValue = + | { set: false } + | { set: true; value: T } ; + +export function createInitializableState(args: CreateInitializableStateArgs): Injectable, unknown, void> { + const { id, init, injectionToken } = args; + + return getInjectable({ + id, + instantiate: (di) => { + let box: InitializableStateValue = { + set: false, + }; + let initCalled = false; + + return { + init: async () => { + if (initCalled) { + throw new Error(`Cannot initialize InitializableState(${id}) more than once`); + } + + initCalled = true; + box = { + set: true, + value: await init(di), + }; + }, + get: () => { + if (!initCalled) { + throw new Error(`InitializableState(${id}) has not been initialized yet`); + } + + if (box.set === false) { + throw new Error(`InitializableState(${id}) has not finished initializing`); + } + + return box.value; + }, + }; + }, + injectionToken, + }); +} diff --git a/src/common/ipc/__tests__/type-enforced-ipc.test.ts b/src/common/ipc/__tests__/type-enforced-ipc.test.ts deleted file mode 100644 index bd456257cb..0000000000 --- a/src/common/ipc/__tests__/type-enforced-ipc.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { EventEmitter } from "events"; -import { onCorrect, onceCorrect } from "../type-enforced-ipc"; - -describe("type enforced ipc tests", () => { - describe("onCorrect tests", () => { - it("should call the handler if the args are valid", () => { - let called = false; - const source = new EventEmitter(); - const listener = () => called = true; - const verifier = (args: unknown[]): args is [] => true; - const channel = "foobar"; - - onCorrect({ source, listener, verifier, channel }); - - source.emit(channel); - expect(called).toBe(true); - }); - - it("should not call the handler if the args are not valid", () => { - let called = false; - const source = new EventEmitter(); - const listener = () => called = true; - const verifier = (args: unknown[]): args is [] => false; - const channel = "foobar"; - - onCorrect({ source, listener, verifier, channel }); - - source.emit(channel); - expect(called).toBe(false); - }); - - it("should call the handler twice if the args are valid on two emits", () => { - let called = 0; - const source = new EventEmitter(); - const listener = () => called += 1; - const verifier = (args: unknown[]): args is [] => true; - const channel = "foobar"; - - onCorrect({ source, listener, verifier, channel }); - - source.emit(channel); - source.emit(channel); - expect(called).toBe(2); - }); - - it("should call the handler twice if the args are [valid, invalid, valid]", () => { - let called = 0; - const source = new EventEmitter(); - const listener = () => called += 1; - const results = [true, false, true]; - const verifier = (args: unknown[]): args is [] => results.pop() ?? false; - const channel = "foobar"; - - onCorrect({ source, listener, verifier, channel }); - - source.emit(channel); - source.emit(channel); - source.emit(channel); - expect(called).toBe(2); - }); - }); - - describe("onceCorrect tests", () => { - it("should call the handler if the args are valid", () => { - let called = false; - const source = new EventEmitter(); - const listener = () => called = true; - const verifier = (args: unknown[]): args is [] => true; - const channel = "foobar"; - - onceCorrect({ source, listener, verifier, channel }); - - source.emit(channel); - expect(called).toBe(true); - }); - - it("should not call the handler if the args are not valid", () => { - let called = false; - const source = new EventEmitter(); - const listener = () => called = true; - const verifier = (args: unknown[]): args is [] => false; - const channel = "foobar"; - - onceCorrect({ source, listener, verifier, channel }); - - source.emit(channel); - expect(called).toBe(false); - }); - - it("should call the handler only once even if args are valid multiple times", () => { - let called = 0; - const source = new EventEmitter(); - const listener = () => called += 1; - const verifier = (args: unknown[]): args is [] => true; - const channel = "foobar"; - - onceCorrect({ source, listener, verifier, channel }); - - source.emit(channel); - source.emit(channel); - expect(called).toBe(1); - }); - - it("should call the handler on only the first valid set of args", () => { - let called = ""; - let verifierCalled = 0; - const source = new EventEmitter(); - const listener = (info: any, arg: string) => called = arg; - const verifier = (args: unknown[]): args is [string] => (++verifierCalled) % 3 === 0; - const channel = "foobar"; - - onceCorrect({ source, listener, verifier, channel }); - - source.emit(channel, {}, "a"); - source.emit(channel, {}, "b"); - source.emit(channel, {}, "c"); - source.emit(channel, {}, "d"); - source.emit(channel, {}, "e"); - source.emit(channel, {}, "f"); - source.emit(channel, {}, "g"); - source.emit(channel, {}, "h"); - source.emit(channel, {}, "i"); - expect(called).toBe("c"); - }); - }); -}); diff --git a/src/common/ipc/cluster.ts b/src/common/ipc/cluster.ts index 9f69ff42d5..53061abf60 100644 --- a/src/common/ipc/cluster.ts +++ b/src/common/ipc/cluster.ts @@ -6,13 +6,7 @@ export const clusterActivateHandler = "cluster:activate"; export const clusterSetFrameIdHandler = "cluster:set-frame-id"; export const clusterVisibilityHandler = "cluster:visibility"; -export const clusterRefreshHandler = "cluster:refresh"; export const clusterDisconnectHandler = "cluster:disconnect"; -export const clusterDeleteHandler = "cluster:delete"; -export const clusterSetDeletingHandler = "cluster:deleting:set"; -export const clusterClearDeletingHandler = "cluster:deleting:clear"; -export const clusterKubectlApplyAllHandler = "cluster:kubectl-apply-all"; -export const clusterKubectlDeleteAllHandler = "cluster:kubectl-delete-all"; export const clusterStates = "cluster:states"; /** diff --git a/src/common/ipc/index.ts b/src/common/ipc/index.ts index bb60ce4f6c..aa2a538560 100644 --- a/src/common/ipc/index.ts +++ b/src/common/ipc/index.ts @@ -5,4 +5,3 @@ export * from "./ipc"; export * from "./invalid-kubeconfig"; -export * from "./type-enforced-ipc"; diff --git a/src/common/ipc/type-enforced-ipc.ts b/src/common/ipc/type-enforced-ipc.ts deleted file mode 100644 index d37dce2f85..0000000000 --- a/src/common/ipc/type-enforced-ipc.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { EventEmitter } from "events"; -import { ipcMain } from "electron"; -import logger from "../../main/logger"; -import type { Disposer } from "../utils"; -import { ipcMainHandle } from "./ipc"; - -export type ListenerEvent = Parameters[1]>[0]; -export type ListVerifier = (args: unknown[]) => args is T; -export type Rest = T extends [any, ...infer R] ? R : []; - -/** - * Adds a listener to `source` that waits for the first IPC message with the correct - * argument data is sent. - * @param channel The channel to be listened on - * @param listener The function for the channel to be called if the args of the correct type - * @param verifier The function to be called to verify that the args are the correct type - */ -export function onceCorrect< - IPC extends EventEmitter, - Listener extends (event: ListenerEvent, ...args: any[]) => any, ->({ - source, - channel, - listener, - verifier, -}: { - source: IPC; - channel: string; - listener: Listener; - verifier: ListVerifier>>; -}): void { - function wrappedListener(event: ListenerEvent, ...args: unknown[]): void { - if (verifier(args)) { - source.removeListener(channel, wrappedListener); // remove immediately - - (async () => (listener(event, ...args)))() // might return a promise, or throw, or reject - .catch((error: any) => logger.error("[IPC]: channel once handler threw error", { channel, error })); - } else { - logger.error("[IPC]: channel was emitted with invalid data", { channel, args }); - } - } - - source.on(channel, wrappedListener); -} - -/** - * Adds a listener to `source` that checks to verify the arguments before calling the handler. - * @param channel The channel to be listened on - * @param listener The function for the channel to be called if the args of the correct type - * @param verifier The function to be called to verify that the args are the correct type - */ -export function onCorrect< - IPC extends EventEmitter, - Listener extends (event: ListenerEvent, ...args: any[]) => any, ->({ - source, - channel, - listener, - verifier, -}: { - source: IPC; - channel: string; - listener: Listener; - verifier: ListVerifier>>; -}): Disposer { - function wrappedListener(event: ListenerEvent, ...args: unknown[]) { - if (verifier(args)) { - (async () => (listener(event, ...args)))() // might return a promise, or throw, or reject - .catch(error => logger.error("[IPC]: channel on handler threw error", { channel, error })); - } else { - logger.error("[IPC]: channel was emitted with invalid data", { channel, args }); - } - } - - source.on(channel, wrappedListener); - - return () => source.off(channel, wrappedListener); -} - -export function handleCorrect< - Handler extends (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any, ->({ - channel, - handler, - verifier, -}: { - channel: string; - handler: Handler; - verifier: ListVerifier>>; -}): Disposer { - function wrappedHandler(event: Electron.IpcMainInvokeEvent, ...args: unknown[]): ReturnType { - if (verifier(args)) { - return handler(event, ...args); - } - - throw new TypeError(`Invalid args for invoke on channel: ${channel}`); - } - - ipcMainHandle(channel, wrappedHandler); - - return () => ipcMain.removeHandler(channel); -} diff --git a/src/common/k8s-api/__tests__/kube-api-version-detection.test.ts b/src/common/k8s-api/__tests__/kube-api-version-detection.test.ts new file mode 100644 index 0000000000..eb13464716 --- /dev/null +++ b/src/common/k8s-api/__tests__/kube-api-version-detection.test.ts @@ -0,0 +1,719 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { KubeJsonApi } from "../kube-json-api"; +import { PassThrough } from "stream"; +import type { ApiManager } from "../api-manager"; +import { Ingress, IngressApi } from "../endpoints"; +import { getDiForUnitTesting } from "../../../renderer/getDiForUnitTesting"; +import apiManagerInjectable from "../api-manager/manager.injectable"; +import autoRegistrationInjectable from "../api-manager/auto-registration.injectable"; +import type { Fetch } from "../../fetch/fetch.injectable"; +import fetchInjectable from "../../fetch/fetch.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { flushPromises } from "../../test-utils/flush-promises"; +import createKubeJsonApiInjectable from "../create-kube-json-api.injectable"; +import type { Response, Headers as NodeFetchHeaders } from "node-fetch"; + +const createMockResponseFromString = (url: string, data: string, statusCode = 200) => { + const res: jest.Mocked = { + buffer: jest.fn(async () => { throw new Error("buffer() is not supported"); }), + clone: jest.fn(() => res), + arrayBuffer: jest.fn(async () => { throw new Error("arrayBuffer() is not supported"); }), + blob: jest.fn(async () => { throw new Error("blob() is not supported"); }), + body: new PassThrough(), + bodyUsed: false, + headers: new Headers() as NodeFetchHeaders, + json: jest.fn(async () => JSON.parse(await res.text())), + ok: 200 <= statusCode && statusCode < 300, + redirected: 300 <= statusCode && statusCode < 400, + size: data.length, + status: statusCode, + statusText: "some-text", + text: jest.fn(async () => data), + type: "basic", + url, + formData: jest.fn(async () => { throw new Error("formData() is not supported"); }), + }; + + return res; +}; + +describe("KubeApi", () => { + let request: KubeJsonApi; + let registerApiSpy: jest.SpiedFunction; + let fetchMock: AsyncFnMock; + + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + fetchMock = asyncFn(); + di.override(fetchInjectable, () => fetchMock); + + const createKubeJsonApi = di.inject(createKubeJsonApiInjectable); + + request = createKubeJsonApi({ + serverAddress: `http://127.0.0.1:9999`, + apiBase: "/api-kube", + }); + registerApiSpy = jest.spyOn(di.inject(apiManagerInjectable), "registerApi"); + + di.inject(autoRegistrationInjectable); + }); + + describe("on first call to IngressApi.get()", () => { + let ingressApi: IngressApi; + let getCall: Promise; + + beforeEach(async () => { + ingressApi = new IngressApi({ + request, + objectConstructor: Ingress, + apiBase: "/apis/networking.k8s.io/v1/ingresses", + fallbackApiBases: ["/apis/extensions/v1beta1/ingresses"], + checkPreferredVersion: true, + }); + getCall = ingressApi.get({ + name: "foo", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("requests version list from the api group from the initial apiBase", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the version list from the api group resolves", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io", JSON.stringify({ + apiVersion: "v1", + kind: "APIGroup", + name: "networking.k8s.io", + versions: [ + { + groupVersion: "networking.k8s.io/v1", + version: "v1", + }, + { + groupVersion: "networking.k8s.io/v1beta1", + version: "v1beta1", + }, + ], + preferredVersion: { + groupVersion: "networking.k8s.io/v1", + version: "v1", + }, + })), + ); + }); + + it("requests resources from the versioned api group from the initial apiBase", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when resource request fufills with a resource", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1", JSON.stringify({ + resources: [{ + name: "ingresses", + }], + })), + ); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + it("sets fields in the api instance", () => { + expect(ingressApi).toEqual(expect.objectContaining({ + apiVersionPreferred: "v1", + apiPrefix: "/apis", + apiGroup: "networking.k8s.io", + })); + }); + + it("registers the api with the changes info", () => { + expect(registerApiSpy).toBeCalledWith(ingressApi); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + + describe("on the second call to IngressApi.get()", () => { + let getCall: Promise; + + beforeEach(async () => { + getCall = ingressApi.get({ + name: "foo1", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo1", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + }); + }); + }); + + describe("when the request resolves with data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo", JSON.stringify({ + apiVersion: "v1", + kind: "Ingress", + metadata: { + name: "foo", + namespace: "default", + resourceVersion: "1", + uid: "12345", + }, + })), + ); + result = await getCall; + }); + + it("results in the get call resolving to an instance", () => { + expect(result).toBeInstanceOf(Ingress); + }); + + describe("on the second call to IngressApi.get()", () => { + let getCall: Promise; + + beforeEach(async () => { + getCall = ingressApi.get({ + name: "foo1", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo1", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + }); + }); + }); + }); + + describe("when resource request fufills with no resource", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1", JSON.stringify({ + resources: [], + })), + ); + }); + + it("requests resources from the second versioned api group from the initial apiBase", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + + + describe("when resource request fufills with a resource", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1", JSON.stringify({ + resources: [{ + name: "ingresses", + }], + })), + ); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + it("sets fields in the api instance", () => { + expect(ingressApi).toEqual(expect.objectContaining({ + apiVersionPreferred: "v1beta1", + apiPrefix: "/apis", + apiGroup: "networking.k8s.io", + })); + }); + + it("registers the api with the changes info", () => { + expect(registerApiSpy).toBeCalledWith(ingressApi); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + + describe("on the second call to IngressApi.get()", () => { + let getCall: Promise; + + beforeEach(async () => { + getCall = ingressApi.get({ + name: "foo1", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo1", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + }); + }); + }); + + describe("when the request resolves with data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo", JSON.stringify({ + apiVersion: "v1", + kind: "Ingress", + metadata: { + name: "foo", + namespace: "default", + resourceVersion: "1", + uid: "12345", + }, + })), + ); + result = await getCall; + }); + + it("results in the get call resolving to an instance", () => { + expect(result).toBeInstanceOf(Ingress); + }); + + describe("on the second call to IngressApi.get()", () => { + let getCall: Promise; + + beforeEach(async () => { + getCall = ingressApi.get({ + name: "foo1", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo1", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + }); + }); + }); + }); + }); + }); + + describe("when the version list from the api group resolves with no versions", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io", JSON.stringify({ + "metadata": {}, + "status": "Failure", + "message": "the server could not find the requested resource", + "reason": "NotFound", + "details": { + "causes": [ + { + "reason": "UnexpectedServerResponse", + "message": "404 page not found", + }, + ], + }, + "code": 404, + }), 404), + ); + }); + + it("requests the resources from the base api url from the fallback api", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/extensions", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when resource request fufills with a resource", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/extensions"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/extensions", JSON.stringify({ + apiVersion: "v1", + kind: "APIGroup", + name: "extensions", + versions: [ + { + groupVersion: "extensions/v1beta1", + version: "v1beta1", + }, + ], + preferredVersion: { + groupVersion: "extensions/v1beta1", + version: "v1beta1", + }, + })), + ); + }); + + it("requests resource versions from the versioned api group from the fallback apiBase", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the preferred version request resolves to v1beta1", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/extensions", JSON.stringify({ + resources: [{ + name: "ingresses", + }], + })), + ); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + it("sets fields in the api instance", () => { + expect(ingressApi).toEqual(expect.objectContaining({ + apiVersionPreferred: "v1beta1", + apiPrefix: "/apis", + apiGroup: "extensions", + })); + }); + + it("registers the api with the changes info", () => { + expect(registerApiSpy).toBeCalledWith(ingressApi); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + + describe("on the second call to IngressApi.get()", () => { + let getCall: Promise; + + beforeEach(async () => { + getCall = ingressApi.get({ + name: "foo1", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo1", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + }); + }); + }); + + describe("when the request resolves with data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo", JSON.stringify({ + apiVersion: "v1beta1", + kind: "Ingress", + metadata: { + name: "foo", + namespace: "default", + resourceVersion: "1", + uid: "12345", + }, + })), + ); + result = await getCall; + }); + + it("results in the get call resolving to an instance", () => { + expect(result).toBeInstanceOf(Ingress); + }); + + describe("on the second call to IngressApi.get()", () => { + let getCall: Promise; + + beforeEach(async () => { + getCall = ingressApi.get({ + name: "foo1", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo1", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/src/common/k8s-api/__tests__/kube-api.test.ts b/src/common/k8s-api/__tests__/kube-api.test.ts index 05da1f89d5..4f67ed1401 100644 --- a/src/common/k8s-api/__tests__/kube-api.test.ts +++ b/src/common/k8s-api/__tests__/kube-api.test.ts @@ -2,37 +2,97 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ - -import { forRemoteCluster, KubeApi } from "../kube-api"; -import { KubeJsonApi } from "../kube-json-api"; -import { KubeObject } from "../kube-object"; -import { delay } from "../../utils/delay"; +import type { KubeApiWatchCallback } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { KubeJsonApi, KubeJsonApiData } from "../kube-json-api"; import { PassThrough } from "stream"; -import { ApiManager } from "../api-manager"; -import type { FetchMock } from "jest-fetch-mock/types"; -import { DeploymentApi, Ingress, IngressApi, Pod, PodApi } from "../endpoints"; +import { Deployment, DeploymentApi, NamespaceApi, Pod, PodApi } from "../endpoints"; import { getDiForUnitTesting } from "../../../renderer/getDiForUnitTesting"; -import apiManagerInjectable from "../api-manager/manager.injectable"; import autoRegistrationInjectable from "../api-manager/auto-registration.injectable"; +import type { Fetch } from "../../fetch/fetch.injectable"; +import fetchInjectable from "../../fetch/fetch.injectable"; +import type { CreateKubeApiForRemoteCluster } from "../create-kube-api-for-remote-cluster.injectable"; +import createKubeApiForRemoteClusterInjectable from "../create-kube-api-for-remote-cluster.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { flushPromises } from "../../test-utils/flush-promises"; +import createKubeJsonApiInjectable from "../create-kube-json-api.injectable"; +import type { IKubeWatchEvent } from "../kube-watch-event"; +import type { KubeJsonApiDataFor } from "../kube-object"; +import type { Response, Headers as NodeFetchHeaders } from "node-fetch"; import AbortController from "abort-controller"; -jest.mock("../api-manager"); +const createMockResponseFromString = (url: string, data: string, statusCode = 200) => { + const res: jest.Mocked = { + buffer: jest.fn(async () => { throw new Error("buffer() is not supported"); }), + clone: jest.fn(() => res), + arrayBuffer: jest.fn(async () => { throw new Error("arrayBuffer() is not supported"); }), + blob: jest.fn(async () => { throw new Error("blob() is not supported"); }), + body: new PassThrough(), + bodyUsed: false, + headers: new Headers() as NodeFetchHeaders, + json: jest.fn(async () => JSON.parse(await res.text())), + ok: 200 <= statusCode && statusCode < 300, + redirected: 300 <= statusCode && statusCode < 400, + size: data.length, + status: statusCode, + statusText: "some-text", + text: jest.fn(async () => data), + type: "basic", + url, + formData: jest.fn(async () => { throw new Error("formData() is not supported"); }), + }; -const mockFetch = fetch as FetchMock; + return res; +}; -describe("forRemoteCluster", () => { - let apiManager: jest.Mocked; +const createMockResponseFromStream = (url: string, stream: NodeJS.ReadableStream, statusCode = 200) => { + const res: jest.Mocked = { + buffer: jest.fn(async () => { throw new Error("buffer() is not supported"); }), + clone: jest.fn(() => res), + arrayBuffer: jest.fn(async () => { throw new Error("arrayBuffer() is not supported"); }), + blob: jest.fn(async () => { throw new Error("blob() is not supported"); }), + body: stream, + bodyUsed: false, + headers: new Headers() as NodeFetchHeaders, + json: jest.fn(async () => JSON.parse(await res.text())), + ok: 200 <= statusCode && statusCode < 300, + redirected: 300 <= statusCode && statusCode < 400, + size: 10, + status: statusCode, + statusText: "some-text", + text: jest.fn(() => { + const chunks: Buffer[] = []; - beforeEach(() => { + return new Promise((resolve, reject) => { + stream.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + stream.on("error", (err) => reject(err)); + stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + }); + }), + type: "basic", + url, + formData: jest.fn(async () => { throw new Error("formData() is not supported"); }), + }; + + return res; +}; + +describe("createKubeApiForRemoteCluster", () => { + let createKubeApiForRemoteCluster: CreateKubeApiForRemoteCluster; + let fetchMock: AsyncFnMock; + + beforeEach(async () => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); - apiManager = new ApiManager() as jest.Mocked; + fetchMock = asyncFn(); + di.override(fetchInjectable, () => fetchMock); - di.override(apiManagerInjectable, () => apiManager); + createKubeApiForRemoteCluster = di.inject(createKubeApiForRemoteClusterInjectable); }); it("builds api client for KubeObject", async () => { - const api = forRemoteCluster({ + const api = createKubeApiForRemoteCluster({ cluster: { server: "https://127.0.0.1:6443", }, @@ -44,232 +104,90 @@ describe("forRemoteCluster", () => { expect(api).toBeInstanceOf(KubeApi); }); - it("builds api client for given KubeApi", async () => { - const api = forRemoteCluster({ - cluster: { - server: "https://127.0.0.1:6443", - }, - user: { - token: "daa", - }, - }, Pod, PodApi); + describe("when building for remote cluster with specific constructor", () => { + let api: PodApi; - expect(api).toBeInstanceOf(PodApi); - }); - - it("calls right api endpoint", async () => { - const api = forRemoteCluster({ - cluster: { - server: "https://127.0.0.1:6443", - }, - user: { - token: "daa", - }, - }, Pod); - - mockFetch.mockResponse(async (request: any) => { - expect(request.url).toEqual("https://127.0.0.1:6443/api/v1/pods"); - - return { - body: "hello", - }; + beforeEach(() => { + api = createKubeApiForRemoteCluster({ + cluster: { + server: "https://127.0.0.1:6443", + }, + user: { + token: "daa", + }, + }, Pod, PodApi); }); - expect.hasAssertions(); + it("uses the constructor", () => { + expect(api).toBeInstanceOf(PodApi); + }); - await api.list(); + describe("when calling list without namespace", () => { + let listRequest: Promise; + + beforeEach(async () => { + listRequest = api.list(); + + // This is required because of how JS promises work + await flushPromises(); + }); + + it("should request pods from default namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "https://127.0.0.1:6443/api/v1/pods", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when request resolves with data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["https://127.0.0.1:6443/api/v1/pods"], + createMockResponseFromString("https://127.0.0.1:6443/api/v1/pods", JSON.stringify({ + kind: "PodList", + apiVersion: "v1", + metadata:{ + resourceVersion: "452899", + }, + items: [], + })), + ); + }); + + it("resolves the list call", async () => { + expect(await listRequest).toEqual([]); + }); + }); + }); }); }); describe("KubeApi", () => { let request: KubeJsonApi; - let apiManager: jest.Mocked; + let fetchMock: AsyncFnMock; - beforeEach(() => { + beforeEach(async () => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); - request = new KubeJsonApi({ + fetchMock = asyncFn(); + di.override(fetchInjectable, () => fetchMock); + + const createKubeJsonApi = di.inject(createKubeJsonApiInjectable); + + request = createKubeJsonApi({ serverAddress: `http://127.0.0.1:9999`, apiBase: "/api-kube", }); - apiManager = new ApiManager() as jest.Mocked; - di.override(apiManagerInjectable, () => apiManager); di.inject(autoRegistrationInjectable); }); - it("uses url from apiBase if apiBase contains the resource", async () => { - mockFetch.mockResponse(async (request: any) => { - if (request.url === "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1") { - return { - body: JSON.stringify({ - resources: [{ - name: "ingresses", - }], - }), - }; - } else if (request.url === "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1") { - // Even if the old API contains ingresses, KubeApi should prefer the apiBase url - return { - body: JSON.stringify({ - resources: [{ - name: "ingresses", - }], - }), - }; - } else { - return { - body: JSON.stringify({ - resources: [], - }), - }; - } - }); - - const apiBase = "/apis/networking.k8s.io/v1/ingresses"; - const fallbackApiBase = "/apis/extensions/v1beta1/ingresses"; - const kubeApi = new IngressApi({ - request, - objectConstructor: Ingress, - apiBase, - fallbackApiBases: [fallbackApiBase], - checkPreferredVersion: true, - }); - - await kubeApi.get({ - name: "foo", - namespace: "default", - }); - expect(kubeApi.apiPrefix).toEqual("/apis"); - expect(kubeApi.apiGroup).toEqual("networking.k8s.io"); - }); - - it("uses url from fallbackApiBases if apiBase lacks the resource", async () => { - mockFetch.mockResponse(async (request: any) => { - if (request.url === "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1") { - return { - body: JSON.stringify({ - resources: [], - }), - }; - } else if (request.url === "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1") { - return { - body: JSON.stringify({ - resources: [{ - name: "ingresses", - }], - }), - }; - } else { - return { - body: JSON.stringify({ - resources: [], - }), - }; - } - }); - - const apiBase = "apis/networking.k8s.io/v1/ingresses"; - const fallbackApiBase = "/apis/extensions/v1beta1/ingresses"; - const kubeApi = new IngressApi({ - request, - objectConstructor: Object.assign(KubeObject, { apiBase }), - kind: "Ingress", - fallbackApiBases: [fallbackApiBase], - checkPreferredVersion: true, - }); - - await kubeApi.get({ - name: "foo", - namespace: "default", - }); - expect(kubeApi.apiPrefix).toEqual("/apis"); - expect(kubeApi.apiGroup).toEqual("extensions"); - }); - - describe("checkPreferredVersion", () => { - it("registers with apiManager if checkPreferredVersion changes apiVersionPreferred", async () => { - expect.hasAssertions(); - - const api = new IngressApi({ - objectConstructor: Ingress, - checkPreferredVersion: true, - fallbackApiBases: ["/apis/extensions/v1beta1/ingresses"], - request: { - get: jest.fn() - .mockImplementation((path: string) => { - switch (path) { - case "/apis/networking.k8s.io/v1": - throw new Error("no"); - case "/apis/extensions/v1beta1": - return { - resources: [ - { - name: "ingresses", - }, - ], - }; - case "/apis/extensions": - return { - preferredVersion: { - version: "v1beta1", - }, - }; - default: - throw new Error("unknown path"); - } - }), - } as Partial as KubeJsonApi, - }); - - await (api as any).checkPreferredVersion(); - - expect(api.apiVersionPreferred).toBe("v1beta1"); - expect(apiManager.registerApi).toBeCalledWith(api); - }); - - it("registers with apiManager if checkPreferredVersion changes apiVersionPreferred with non-grouped apis", async () => { - expect.hasAssertions(); - - const api = new PodApi({ - objectConstructor: Pod, - checkPreferredVersion: true, - fallbackApiBases: ["/api/v1beta1/pods"], - request: { - get: jest.fn() - .mockImplementation((path: string) => { - switch (path) { - case "/api/v1": - throw new Error("no"); - case "/api/v1beta1": - return { - resources: [ - { - name: "pods", - }, - ], - }; - case "/api": - return { - preferredVersion: { - version: "v1beta1", - }, - }; - default: - throw new Error("unknown path"); - } - }), - } as Partial as KubeJsonApi, - }); - - await (api as any).checkPreferredVersion(); - - expect(api.apiVersionPreferred).toBe("v1beta1"); - expect(apiManager.registerApi).toBeCalledWith(api); - }); - }); - - describe("patch", () => { + describe("patching deployments", () => { let api: DeploymentApi; beforeEach(() => { @@ -278,140 +196,377 @@ describe("KubeApi", () => { }); }); - it("sends strategic patch by default", async () => { - expect.hasAssertions(); + describe("when patching a resource without providing a strategy", () => { + let patchRequest: Promise; - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("PATCH"); - expect(request.headers.get("content-type")).toMatch("strategic-merge-patch"); - expect(request.body?.toString()).toEqual(JSON.stringify({ spec: { replicas: 2 }})); + beforeEach(async () => { + patchRequest = api.patch({ name: "test", namespace: "default" }, { + spec: { replicas: 2 }, + }); - return {}; + // This is needed because of how JS promises work + await flushPromises(); }); - await api.patch({ name: "test", namespace: "default" }, { - spec: { replicas: 2 }, + it("requests a patch using strategic merge", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test", + { + headers: { + "content-type": "application/strategic-merge-patch+json", + }, + method: "patch", + body: JSON.stringify({ spec: { replicas: 2 }}), + }, + ]); + }); + + describe("when the patch request resolves with data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test", JSON.stringify({ + apiVersion: "v1", + kind: "Deployment", + metadata: { + name: "test", + namespace: "default", + resourceVersion: "1", + uid: "12345", + }, + spec: { + replicas: 2, + }, + })), + ); + }); + + it("resolves the patch call", async () => { + expect(await patchRequest).toBeInstanceOf(Deployment); + }); }); }); - it("allows to use merge patch", async () => { - expect.hasAssertions(); + describe("when patching a resource using json patch", () => { + let patchRequest: Promise; - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("PATCH"); - expect(request.headers.get("content-type")).toMatch("merge-patch"); - expect(request.body?.toString()).toEqual(JSON.stringify({ spec: { replicas: 2 }})); + beforeEach(async () => { + patchRequest = api.patch({ name: "test", namespace: "default" }, [ + { op: "replace", path: "/spec/replicas", value: 2 }, + ], "json"); - return {}; + // This is needed because of how JS promises work + await flushPromises(); }); - await api.patch({ name: "test", namespace: "default" }, { - spec: { replicas: 2 }, - }, "merge"); + it("requests a patch using json merge", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test", + { + headers: { + "content-type": "application/json-patch+json", + }, + method: "patch", + body: JSON.stringify([ + { op: "replace", path: "/spec/replicas", value: 2 }, + ]), + }, + ]); + }); + + describe("when the patch request resolves with data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test", JSON.stringify({ + apiVersion: "v1", + kind: "Deployment", + metadata: { + name: "test", + namespace: "default", + resourceVersion: "1", + uid: "12345", + }, + spec: { + replicas: 2, + }, + })), + ); + }); + + it("resolves the patch call", async () => { + expect(await patchRequest).toBeInstanceOf(Deployment); + }); + }); }); - it("allows to use json patch", async () => { - expect.hasAssertions(); + describe("when patching a resource using merge patch", () => { + let patchRequest: Promise; - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("PATCH"); - expect(request.headers.get("content-type")).toMatch("json-patch"); - expect(request.body?.toString()).toEqual(JSON.stringify([{ op: "replace", path: "/spec/replicas", value: 2 }])); + beforeEach(async () => { + patchRequest = api.patch( + { name: "test", namespace: "default" }, + { metadata: { annotations: { provisioned: "True" }}}, + "merge", + ); - return {}; + // This is needed because of how JS promises work + await flushPromises(); }); - await api.patch({ name: "test", namespace: "default" }, [ - { op: "replace", path: "/spec/replicas", value: 2 }, - ], "json"); - }); - - it("allows deep partial patch", async () => { - expect.hasAssertions(); - - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("PATCH"); - expect(request.headers.get("content-type")).toMatch("merge-patch"); - expect(request.body?.toString()).toEqual(JSON.stringify({ metadata: { annotations: { provisioned: "true" }}})); - - return {}; + it("requests a patch using json merge", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test", + { + headers: { + "content-type": "application/merge-patch+json", + }, + method: "patch", + body: JSON.stringify({ metadata: { annotations: { provisioned: "True" }}}), + }, + ]); }); - await api.patch( - { name: "test", namespace: "default" }, - { metadata: { annotations: { provisioned: "true" }}}, - "merge", - ); + describe("when the patch request resolves with data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test", JSON.stringify({ + apiVersion: "v1", + kind: "Deployment", + metadata: { + name: "test", + namespace: "default", + resourceVersion: "1", + uid: "12345", + annotations: { + provisioned: "True", + }, + }, + })), + ); + }); + + it("resolves the patch call", async () => { + expect(await patchRequest).toBeInstanceOf(Deployment); + }); + }); }); }); - describe("delete", () => { + describe("deleting pods (namespace scoped resource)", () => { let api: PodApi; beforeEach(() => { api = new PodApi({ request, - objectConstructor: Pod, }); }); - it("sends correct request with empty namespace", async () => { - expect.hasAssertions(); - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("DELETE"); - expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/pods/foo?propagationPolicy=Background"); + describe("when deleting by just name", () => { + let deleteRequest: Promise; - return {}; + beforeEach(async () => { + deleteRequest = api.delete({ name: "foo" }); + + // This is required for how JS promises work + await flushPromises(); }); - await api.delete({ name: "foo", namespace: "" }); + it("requests deleting pod in default namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background", + { + headers: { + "content-type": "application/json", + }, + method: "delete", + }, + ]); + }); + + describe("when request resolves", () => { + beforeEach(async () => { + fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background", "{}"), + ); + }); + + it("resolves the call", async () => { + expect(await deleteRequest).toBeDefined(); + }); + }); }); - it("sends correct request without namespace", async () => { - expect.hasAssertions(); - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("DELETE"); - expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background"); + describe("when deleting by name and empty namespace", () => { + let deleteRequest: Promise; - return {}; + beforeEach(async () => { + deleteRequest = api.delete({ name: "foo", namespace: "" }); + + // This is required for how JS promises work + await flushPromises(); }); - await api.delete({ name: "foo" }); + it("requests deleting pod in default namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background", + { + headers: { + "content-type": "application/json", + }, + method: "delete", + }, + ]); + }); + + describe("when request resolves", () => { + beforeEach(async () => { + fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background", "{}"), + ); + }); + + it("resolves the call", async () => { + expect(await deleteRequest).toBeDefined(); + }); + }); }); - it("sends correct request with namespace", async () => { - expect.hasAssertions(); - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("DELETE"); - expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods/foo?propagationPolicy=Background"); + describe("when deleting by name and namespace", () => { + let deleteRequest: Promise; - return {}; + beforeEach(async () => { + deleteRequest = api.delete({ name: "foo", namespace: "test" }); + + // This is required for how JS promises work + await flushPromises(); }); - await api.delete({ name: "foo", namespace: "kube-system" }); - }); - - it("allows to change propagationPolicy", async () => { - expect.hasAssertions(); - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("DELETE"); - expect(request.url).toMatch("propagationPolicy=Orphan"); - - return {}; + it("requests deleting pod in given namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/test/pods/foo?propagationPolicy=Background", + { + headers: { + "content-type": "application/json", + }, + method: "delete", + }, + ]); }); - await api.delete({ name: "foo", namespace: "default", propagationPolicy: "Orphan" }); + describe("when request resolves", () => { + beforeEach(async () => { + fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/test/pods/foo?propagationPolicy=Background"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/test/pods/foo?propagationPolicy=Background", "{}"), + ); + }); + + it("resolves the call", async () => { + expect(await deleteRequest).toBeDefined(); + }); + }); }); }); - describe("watch", () => { + describe("deleting namespaces (cluser scoped resource)", () => { + let api: NamespaceApi; + + beforeEach(() => { + api = new NamespaceApi({ + request, + }); + }); + + describe("when deleting by just name", () => { + let deleteRequest: Promise; + + beforeEach(async () => { + deleteRequest = api.delete({ name: "foo" }); + + // This is required for how JS promises work + await flushPromises(); + }); + + it("requests deleting Namespace without namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/foo?propagationPolicy=Background", + { + headers: { + "content-type": "application/json", + }, + method: "delete", + }, + ]); + }); + + describe("when request resolves", () => { + beforeEach(async () => { + fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/foo?propagationPolicy=Background"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/foo?propagationPolicy=Background", "{}"), + ); + }); + + it("resolves the call", async () => { + expect(await deleteRequest).toBeDefined(); + }); + }); + }); + + describe("when deleting by name and empty namespace", () => { + let deleteRequest: Promise; + + beforeEach(async () => { + deleteRequest = api.delete({ name: "foo", namespace: "" }); + + // This is required for how JS promises work + await flushPromises(); + }); + + it("requests deleting Namespace without namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/foo?propagationPolicy=Background", + { + headers: { + "content-type": "application/json", + }, + method: "delete", + }, + ]); + }); + + describe("when request resolves", () => { + beforeEach(async () => { + fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/foo?propagationPolicy=Background"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/foo?propagationPolicy=Background", "{}"), + ); + }); + + it("resolves the call", async () => { + expect(await deleteRequest).toBeDefined(); + }); + }); + }); + + describe("when deleting by name and namespace", () => { + it("rejects request", () => { + expect(api.delete({ name: "foo", namespace: "test" })).rejects.toBeDefined(); + }); + }); + }); + + describe("watching pods", () => { let api: PodApi; let stream: PassThrough; beforeEach(() => { api = new PodApi({ request, - objectConstructor: Pod, }); stream = new PassThrough(); }); @@ -421,184 +576,341 @@ describe("KubeApi", () => { stream.destroy(); }); - it("sends a valid watch request", () => { - const spy = jest.spyOn(request, "getResponse"); + describe("when watching in a namespace", () => { + let stopWatch: () => void; + let callback: jest.MockedFunction; - mockFetch.mockResponse(async () => { - return { - // needed for https://github.com/jefflau/jest-fetch-mock/issues/218 - body: stream as unknown as string, - }; + beforeEach(async () => { + callback = jest.fn(); + stopWatch = api.watch({ + namespace: "kube-system", + callback, + }); + + await flushPromises(); }); - api.watch({ namespace: "kube-system" }); - expect(spy).toHaveBeenCalledWith("/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=", expect.anything(), expect.anything()); + it("requests the watch", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=600", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with a stream", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ([url, init]) => { + const isMatch = url === "http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=600"; + + if (isMatch) { + init?.signal?.addEventListener("abort", () => { + stream.destroy(); + }); + } + + return isMatch; + }, + createMockResponseFromStream("http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=600", stream), + ); + }); + + describe("when some data comes back on the stream", () => { + beforeEach(() => { + stream.emit("data", `${JSON.stringify({ + type: "ADDED", + object: { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "foobar", + namespace: "kube-system", + resourceVersion: "1", + uid: "123456", + }, + }, + } as IKubeWatchEvent>)}\n`); + }); + + it("calls the callback with the data", () => { + expect(callback).toBeCalledWith( + { + type: "ADDED", + object: { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "foobar", + namespace: "kube-system", + resourceVersion: "1", + selfLink: "/api/v1/namespaces/kube-system/pods/foobar", + uid: "123456", + }, + }, + }, + null, + ); + }); + + describe("when stopping the watch", () => { + beforeEach(() => { + stopWatch(); + }); + + it("closes the stream", () => { + expect(stream.destroyed).toBe(true); + }); + }); + }); + }); }); - it("sends timeout as a query parameter", async () => { - const spy = jest.spyOn(request, "getResponse"); + describe("when watching in a namespace with an abort controller provided", () => { + let callback: jest.MockedFunction; + let abortController: AbortController; - mockFetch.mockResponse(async () => { - return { - // needed for https://github.com/jefflau/jest-fetch-mock/issues/218 - body: stream as unknown as string, - }; + beforeEach(async () => { + callback = jest.fn(); + abortController = new AbortController(); + api.watch({ + namespace: "kube-system", + callback, + abortController, + }); + + await flushPromises(); }); - api.watch({ namespace: "kube-system", timeout: 60 }); - expect(spy).toHaveBeenCalledWith("/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=", { query: { timeoutSeconds: 60 }}, expect.anything()); + it("requests the watch", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=600", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with a stream", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ([url, init]) => { + const isMatch = url === "http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=600"; + + if (isMatch) { + init?.signal?.addEventListener("abort", () => { + stream.destroy(); + }); + } + + return isMatch; + }, + createMockResponseFromStream("http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=600", stream), + ); + }); + + describe("when some data comes back on the stream", () => { + beforeEach(() => { + stream.emit("data", `${JSON.stringify({ + type: "ADDED", + object: { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "foobar", + namespace: "kube-system", + resourceVersion: "1", + uid: "123456", + }, + }, + } as IKubeWatchEvent>)}\n`); + }); + + it("calls the callback with the data", () => { + expect(callback).toBeCalledWith( + { + type: "ADDED", + object: { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "foobar", + namespace: "kube-system", + resourceVersion: "1", + selfLink: "/api/v1/namespaces/kube-system/pods/foobar", + uid: "123456", + }, + }, + }, + null, + ); + }); + + describe("when stopping the watch via the controller", () => { + beforeEach(() => { + abortController.abort(); + }); + + it("closes the stream", () => { + expect(stream.destroyed).toBe(true); + }); + }); + }); + }); }); - it("aborts watch using abortController", (done) => { - const spy = jest.spyOn(request, "getResponse"); + describe("when watching in a namespace with a timeout", () => { + let stopWatch: () => void; + let callback: jest.MockedFunction; - mockFetch.mockResponse(async request => { - request.signal.addEventListener("abort", () => { - done(); - }); - - return { - // needed for https://github.com/jefflau/jest-fetch-mock/issues/218 - body: stream as unknown as string, - }; - }); - - const abortController = new AbortController(); - - api.watch({ - namespace: "kube-system", - timeout: 60, - abortController, - }); - - expect(spy).toHaveBeenCalledWith("/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=", { query: { timeoutSeconds: 60 }}, expect.anything()); - delay(100).then(() => abortController.abort()); - }); - - describe("retries", () => { - it("if request ended", (done) => { - const spy = jest.spyOn(request, "getResponse"); - - jest.spyOn(stream, "on").mockImplementation((event: string | symbol, callback: Function) => { - // End the request in 100ms. - if (event === "end") { - setTimeout(() => { - callback(); - }, 100); - } - - return stream; - }); - - // we need to mock using jest as jest-fetch-mock doesn't support mocking the body completely - jest.spyOn(global, "fetch").mockImplementation(async () => { - return { - ok: true, - body: stream as never, - } as Partial as Response; - }); - - api.watch({ + beforeEach(async () => { + callback = jest.fn(); + stopWatch = api.watch({ namespace: "kube-system", + callback, + timeout: 60, }); - expect(spy).toHaveBeenCalledTimes(1); - - setTimeout(() => { - expect(spy).toHaveBeenCalledTimes(2); - done(); - }, 2000); + await flushPromises(); }); - it("if request not closed after timeout", (done) => { - const spy = jest.spyOn(request, "getResponse"); - - mockFetch.mockResponse(async () => { - return { - // needed for https://github.com/jefflau/jest-fetch-mock/issues/218 - body: stream as unknown as string, - }; - }); - - const timeoutSeconds = 1; - - api.watch({ - namespace: "kube-system", - timeout: timeoutSeconds, - }); - - expect(spy).toHaveBeenCalledTimes(1); - - setTimeout(() => { - expect(spy).toHaveBeenCalledTimes(2); - done(); - }, timeoutSeconds * 1000 * 1.2); + it("requests the watch", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=60", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); }); - it("retries only once if request ends and timeout is set", (done) => { - const spy = jest.spyOn(request, "getResponse"); + describe("when the request resolves with a stream", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ([url, init]) => { + const isMatch = url === "http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=60"; - jest.spyOn(stream, "on").mockImplementation((event: string | symbol, callback: Function) => { - // End the request in 100ms. - if (event === "end") { - setTimeout(() => { - callback(); - }, 100); - } + if (isMatch) { + init?.signal?.addEventListener("abort", () => { + stream.destroy(); + }); + } - return stream; + return isMatch; + }, + createMockResponseFromStream("http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=60", stream), + ); }); - // we need to mock using jest as jest-fetch-mock doesn't support mocking the body completely - jest.spyOn(global, "fetch").mockImplementation(async () => { - return { - ok: true, - body: stream as never, - } as Partial as Response; + describe("when some data comes back on the stream", () => { + beforeEach(() => { + stream.emit("data", `${JSON.stringify({ + type: "ADDED", + object: { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "foobar", + namespace: "kube-system", + resourceVersion: "1", + uid: "123456", + }, + }, + } as IKubeWatchEvent>)}\n`); + }); + + it("calls the callback with the data", () => { + expect(callback).toBeCalledWith( + { + type: "ADDED", + object: { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "foobar", + namespace: "kube-system", + resourceVersion: "1", + selfLink: "/api/v1/namespaces/kube-system/pods/foobar", + uid: "123456", + }, + }, + }, + null, + ); + }); + + describe("when stopping the watch", () => { + beforeEach(() => { + stopWatch(); + }); + + it("closes the stream", () => { + expect(stream.destroyed).toBe(true); + }); + }); + + describe("when the watch ends", () => { + beforeEach(() => { + stream.end(); + }); + + it("requests a new watch", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=60", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when stopping the watch", () => { + beforeEach(() => { + stopWatch(); + }); + + it("closes the stream", () => { + expect(stream.destroyed).toBe(true); + }); + }); + }); }); - - const timeoutSeconds = 0.5; - - api.watch({ - namespace: "kube-system", - timeout: timeoutSeconds, - }); - - expect(spy).toHaveBeenCalledTimes(1); - - setTimeout(() => { - expect(spy).toHaveBeenCalledTimes(2); - done(); - }, 2000); - }); - - afterEach(() => { - jest.clearAllMocks(); }); }); }); - describe("create", () => { + describe("creating pods", () => { let api: PodApi; beforeEach(() => { api = new PodApi({ request, - objectConstructor: Pod, }); }); - it("should add kind and apiVersion", async () => { - expect.hasAssertions(); + describe("when creating a pod", () => { + let createRequest: Promise; - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("POST"); - expect(JSON.parse(String(request.body))).toEqual({ - kind: "Pod", - apiVersion: "v1", + beforeEach(async () => { + createRequest = api.create({ + name: "foobar", + namespace: "default", + }, { metadata: { - name: "foobar", - namespace: "default", + labels: { + foo: "bar", + }, }, spec: { containers: [ @@ -617,101 +929,324 @@ describe("KubeApi", () => { }, }); - return {}; + // This is required because of how JS promises work + await flushPromises(); }); - await api.create({ - name: "foobar", - namespace: "default", - }, { - spec: { - containers: [ - { - name: "web", - image: "nginx", - ports: [ - { - name: "web", - containerPort: 80, - protocol: "TCP", + it("should request to create a pod with full descriptor", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods", + { + headers: { + "content-type": "application/json", + }, + method: "post", + body: JSON.stringify({ + metadata: { + labels: { + foo: "bar", }, - ], - }, - ], - }, - }); - }); - - it("doesn't override metadata.labels", async () => { - expect.hasAssertions(); - - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("POST"); - expect(JSON.parse(String(request.body))).toEqual({ - kind: "Pod", - apiVersion: "v1", - metadata: { - name: "foobar", - namespace: "default", - labels: { - foo: "bar", - }, + name: "foobar", + namespace: "default", + }, + spec: { + containers: [{ + name: "web", + image: "nginx", + ports: [{ + name: "web", + containerPort: 80, + protocol: "TCP", + }], + }], + }, + kind: "Pod", + apiVersion: "v1", + }), }, + ]); + }); + + describe("when request resolves with data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods", JSON.stringify({ + kind: "Pod", + apiVersion: "v1", + metadata: { + name: "foobar", + namespace: "default", + labels: { + foo: "bar", + }, + resourceVersion: "1", + uid: "123456798", + }, + spec: { + containers: [{ + name: "web", + image: "nginx", + ports: [{ + name: "web", + containerPort: 80, + protocol: "TCP", + }], + }], + }, + })), + ); }); - return {}; - }); - - await api.create({ - name: "foobar", - namespace: "default", - }, { - metadata: { - labels: { - foo: "bar", - }, - }, + it("call should resolve in a Pod instance", async () => { + expect(await createRequest).toBeInstanceOf(Pod); + }); }); }); }); - describe("update", () => { + describe("updating pods", () => { let api: PodApi; beforeEach(() => { api = new PodApi({ request, - objectConstructor: Pod, }); }); - it("doesn't override metadata.labels", async () => { - expect.hasAssertions(); + describe("when updating a pod", () => { + let updateRequest: Promise; - mockFetch.mockResponse(async request => { - expect(request.method).toEqual("PUT"); - expect(JSON.parse(String(request.body))).toEqual({ + beforeEach(async () => { + updateRequest = api.update({ + name: "foobar", + namespace: "default", + }, { + kind: "Pod", + apiVersion: "v1", metadata: { - name: "foobar", - namespace: "default", labels: { foo: "bar", }, }, + spec: { + containers: [{ + name: "web", + image: "nginx", + ports: [{ + name: "web", + containerPort: 80, + protocol: "TCP", + }], + }], + }, }); - return {}; + await flushPromises(); }); - await api.update({ - name: "foobar", - namespace: "default", - }, { - metadata: { - labels: { - foo: "bar", + it("should request that the pod is updated", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foobar", + { + headers: { + "content-type": "application/json", + }, + method: "put", + body: JSON.stringify({ + kind: "Pod", + apiVersion: "v1", + metadata: { + labels: { + foo: "bar", + }, + name: "foobar", + namespace: "default", + }, + spec: { + containers: [{ + name: "web", + image: "nginx", + ports: [{ + name: "web", + containerPort: 80, + protocol: "TCP", + }], + }], + }, + }), }, - }, + ]); + }); + + describe("when the request resolves with data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foobar"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foobar", JSON.stringify({ + kind: "Pod", + apiVersion: "v1", + metadata: { + name: "foobar", + namespace: "default", + labels: { + foo: "bar", + }, + resourceVersion: "1", + uid: "123456798", + }, + spec: { + containers: [{ + name: "web", + image: "nginx", + ports: [{ + name: "web", + containerPort: 80, + protocol: "TCP", + }], + }], + }, + })), + ); + }); + + it("the call should resolve to a Pod", async () => { + expect(await updateRequest).toBeInstanceOf(Pod); + }); + }); + }); + }); + + describe("listing pods", () => { + let api: PodApi; + + beforeEach(() => { + api = new PodApi({ + request, + }); + }); + + describe("when listing pods with no descriptor", () => { + let listRequest: Promise; + + beforeEach(async () => { + listRequest = api.list(); + + await flushPromises(); + }); + + it("should request that the pods from all namespaces", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/pods", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with empty data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/pods"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/pods", JSON.stringify({ + kind: "PodList", + apiVersion: "v1", + metadata: {}, + items: [], + })), + ); + }); + + it("the call should resolve to an empty list", async () => { + expect(await listRequest).toEqual([]); + }); + }); + }); + + describe("when listing pods with descriptor with namespace=''", () => { + let listRequest: Promise; + + beforeEach(async () => { + listRequest = api.list({ + namespace: "", + }); + + await flushPromises(); + }); + + it("should request that the pods from all namespaces", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/pods", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with empty data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/pods"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/pods", JSON.stringify({ + kind: "PodList", + apiVersion: "v1", + metadata: {}, + items: [], + })), + ); + }); + + it("the call should resolve to an empty list", async () => { + expect(await listRequest).toEqual([]); + }); + }); + }); + + describe("when listing pods with descriptor with namespace='default'", () => { + let listRequest: Promise; + + beforeEach(async () => { + listRequest = api.list({ + namespace: "default", + }); + + await flushPromises(); + }); + + it("should request that the pods from just the default namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with empty data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods", JSON.stringify({ + kind: "PodList", + apiVersion: "v1", + metadata: {}, + items: [], + })), + ); + }); + + it("the call should resolve to an empty list", async () => { + expect(await listRequest).toEqual([]); + }); }); }); }); diff --git a/src/common/k8s-api/api-base.ts b/src/common/k8s-api/api-base.ts index e511dd8454..9544e4f60c 100644 --- a/src/common/k8s-api/api-base.ts +++ b/src/common/k8s-api/api-base.ts @@ -3,38 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { JsonApi } from "./json-api"; -import { apiPrefix, isDebugging, isDevelopment } from "../vars"; -import { appEventBus } from "../app-event-bus/event-bus"; +import type { JsonApi } from "./json-api"; +import { getInjectionToken } from "@ogre-tools/injectable"; -export let apiBase: JsonApi; - -if (typeof window === "undefined") { - appEventBus.addListener((event) => { - if (event.name !== "lens-proxy" && event.action !== "listen") return; - - const params = event.params as { port?: number }; - - if (!params.port) return; - - apiBase = new JsonApi({ - serverAddress: `http://127.0.0.1:${params.port}`, - apiBase: apiPrefix, - debug: isDevelopment || isDebugging, - }, { - headers: { - "Host": `localhost:${params.port}`, - }, - }); - }); -} else { - apiBase = new JsonApi({ - serverAddress: `http://127.0.0.1:${window.location.port}`, - apiBase: apiPrefix, - debug: isDevelopment || isDebugging, - }, { - headers: { - "Host": window.location.host, - }, - }); -} +export const apiBaseInjectionToken = getInjectionToken({ + id: "api-base-token", +}); diff --git a/src/common/k8s-api/api-kube.ts b/src/common/k8s-api/api-kube.ts index 4154ec2f00..58aa95208a 100644 --- a/src/common/k8s-api/api-kube.ts +++ b/src/common/k8s-api/api-kube.ts @@ -4,11 +4,8 @@ */ import { getInjectionToken } from "@ogre-tools/injectable"; -import { asLegacyGlobalForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; import type { KubeJsonApi } from "./kube-json-api"; export const apiKubeInjectionToken = getInjectionToken({ id: "api-kube-injection-token", }); - -export const apiKube = asLegacyGlobalForExtensionApi(apiKubeInjectionToken); diff --git a/src/common/k8s-api/create-json-api.injectable.ts b/src/common/k8s-api/create-json-api.injectable.ts new file mode 100644 index 0000000000..aa05a5c157 --- /dev/null +++ b/src/common/k8s-api/create-json-api.injectable.ts @@ -0,0 +1,26 @@ +/** + * 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 type { RequestInit } from "node-fetch"; +import fetchInjectable from "../fetch/fetch.injectable"; +import loggerInjectable from "../logger.injectable"; +import type { JsonApiConfig, JsonApiData, JsonApiDependencies, JsonApiParams } from "./json-api"; +import { JsonApi } from "./json-api"; + +export type CreateJsonApi = = JsonApiParams>(config: JsonApiConfig, reqInit?: RequestInit) => JsonApi; + +const createJsonApiInjectable = getInjectable({ + id: "create-json-api", + instantiate: (di): CreateJsonApi => { + const deps: JsonApiDependencies = { + fetch: di.inject(fetchInjectable), + logger: di.inject(loggerInjectable), + }; + + return (config, reqInit) => new JsonApi(deps, config, reqInit); + }, +}); + +export default createJsonApiInjectable; diff --git a/src/common/k8s-api/create-kube-api-for-cluster.injectable.ts b/src/common/k8s-api/create-kube-api-for-cluster.injectable.ts new file mode 100644 index 0000000000..e7509dcbc7 --- /dev/null +++ b/src/common/k8s-api/create-kube-api-for-cluster.injectable.ts @@ -0,0 +1,63 @@ +/** + * 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 { apiKubePrefix } from "../vars"; +import isDevelopmentInjectable from "../vars/is-development.injectable"; +import { apiBaseInjectionToken } from "./api-base"; +import createKubeJsonApiInjectable from "./create-kube-json-api.injectable"; +import type { KubeApiOptions } from "./kube-api"; +import { KubeApi } from "./kube-api"; +import type { KubeJsonApiDataFor, KubeObject, KubeObjectConstructor } from "./kube-object"; + +export interface CreateKubeApiForLocalClusterConfig { + metadata: { + uid: string; + }; +} + +export interface CreateKubeApiForCluster { + , Data extends KubeJsonApiDataFor>( + cluster: CreateKubeApiForLocalClusterConfig, + kubeClass: KubeObjectConstructor, + apiClass: new (apiOpts: KubeApiOptions) => Api + ): Api; + >( + cluster: CreateKubeApiForLocalClusterConfig, + kubeClass: KubeObjectConstructor, + apiClass?: new (apiOpts: KubeApiOptions) => KubeApi + ): KubeApi; +} + +const createKubeApiForClusterInjectable = getInjectable({ + id: "create-kube-api-for-cluster", + instantiate: (di): CreateKubeApiForCluster => { + const apiBase = di.inject(apiBaseInjectionToken); + const isDevelopment = di.inject(isDevelopmentInjectable); + const createKubeJsonApi = di.inject(createKubeJsonApiInjectable); + + return ( + cluster: CreateKubeApiForLocalClusterConfig, + kubeClass: KubeObjectConstructor>, + apiClass = KubeApi, + ) => ( + new apiClass({ + objectConstructor: kubeClass, + request: createKubeJsonApi( + { + serverAddress: apiBase.config.serverAddress, + apiBase: apiKubePrefix, + debug: isDevelopment, + }, { + headers: { + "Host": `${cluster.metadata.uid}.localhost:${new URL(apiBase.config.serverAddress).port}`, + }, + }, + ), + }) + ); + }, +}); + +export default createKubeApiForClusterInjectable; diff --git a/src/common/k8s-api/create-kube-api-for-remote-cluster.injectable.ts b/src/common/k8s-api/create-kube-api-for-remote-cluster.injectable.ts new file mode 100644 index 0000000000..d11b7ae34b --- /dev/null +++ b/src/common/k8s-api/create-kube-api-for-remote-cluster.injectable.ts @@ -0,0 +1,104 @@ +/** + * 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 type { AgentOptions } from "https"; +import { Agent } from "https"; +import type { RequestInit } from "node-fetch"; +import isDevelopmentInjectable from "../vars/is-development.injectable"; +import createKubeJsonApiInjectable from "./create-kube-json-api.injectable"; +import type { KubeApiOptions } from "./kube-api"; +import { KubeApi } from "./kube-api"; +import type { KubeJsonApiDataFor, KubeObject, KubeObjectConstructor } from "./kube-object"; + +export interface CreateKubeApiForRemoteClusterConfig { + cluster: { + server: string; + caData?: string; + skipTLSVerify?: boolean; + }; + user: { + token?: string | (() => Promise); + clientCertificateData?: string; + clientKeyData?: string; + }; + /** + * Custom instance of https.agent to use for the requests + * + * @remarks the custom agent replaced default agent, options skipTLSVerify, + * clientCertificateData, clientKeyData and caData are ignored. + */ + agent?: Agent; +} + +export interface CreateKubeApiForRemoteCluster { + , Data extends KubeJsonApiDataFor>( + config: CreateKubeApiForRemoteClusterConfig, + kubeClass: KubeObjectConstructor, + apiClass: new (apiOpts: KubeApiOptions) => Api, + ): Api; + >( + config: CreateKubeApiForRemoteClusterConfig, + kubeClass: KubeObjectConstructor, + apiClass?: new (apiOpts: KubeApiOptions) => KubeApi, + ): KubeApi; +} + +const createKubeApiForRemoteClusterInjectable = getInjectable({ + id: "create-kube-api-for-remote-cluster", + instantiate: (di): CreateKubeApiForRemoteCluster => { + const isDevelopment = di.inject(isDevelopmentInjectable); + const createKubeJsonApi = di.inject(createKubeJsonApiInjectable); + + return (config: CreateKubeApiForRemoteClusterConfig, kubeClass: KubeObjectConstructor>, apiClass = KubeApi) => { + const reqInit: RequestInit = {}; + const agentOptions: AgentOptions = {}; + + if (config.cluster.skipTLSVerify === true) { + agentOptions.rejectUnauthorized = false; + } + + if (config.user.clientCertificateData) { + agentOptions.cert = config.user.clientCertificateData; + } + + if (config.user.clientKeyData) { + agentOptions.key = config.user.clientKeyData; + } + + if (config.cluster.caData) { + agentOptions.ca = config.cluster.caData; + } + + if (Object.keys(agentOptions).length > 0) { + reqInit.agent = new Agent(agentOptions); + } + + if (config.agent) { + reqInit.agent = config.agent; + } + + const token = config.user.token; + const request = createKubeJsonApi({ + serverAddress: config.cluster.server, + apiBase: "", + debug: isDevelopment, + ...(token ? { + getRequestOptions: async () => ({ + headers: { + "Authorization": `Bearer ${typeof token === "function" ? await token() : token}`, + }, + }), + } : {}), + }, reqInit); + + return new apiClass({ + objectConstructor: kubeClass, + request, + }); + }; + }, +}); + +export default createKubeApiForRemoteClusterInjectable; diff --git a/src/common/k8s-api/create-kube-json-api-for-cluster.injectable.ts b/src/common/k8s-api/create-kube-json-api-for-cluster.injectable.ts new file mode 100644 index 0000000000..9901731c94 --- /dev/null +++ b/src/common/k8s-api/create-kube-json-api-for-cluster.injectable.ts @@ -0,0 +1,36 @@ +/** + * 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 { apiKubePrefix } from "../vars"; +import isDebuggingInjectable from "../vars/is-debugging.injectable"; +import { apiBaseInjectionToken } from "./api-base"; +import createKubeJsonApiInjectable from "./create-kube-json-api.injectable"; +import type { KubeJsonApi } from "./kube-json-api"; + +export type CreateKubeJsonApiForCluster = (clusterId: string) => KubeJsonApi; + +const createKubeJsonApiForClusterInjectable = getInjectable({ + id: "create-kube-json-api-for-cluster", + instantiate: (di): CreateKubeJsonApiForCluster => { + const apiBase = di.inject(apiBaseInjectionToken); + const createKubeJsonApi = di.inject(createKubeJsonApiInjectable); + const isDebugging = di.inject(isDebuggingInjectable); + + return (clusterId) => createKubeJsonApi( + { + serverAddress: apiBase.config.serverAddress, + apiBase: apiKubePrefix, + debug: isDebugging, + }, + { + headers: { + "Host": `${clusterId}.localhost:${new URL(apiBase.config.serverAddress).port}`, + }, + }, + ); + }, +}); + +export default createKubeJsonApiForClusterInjectable; diff --git a/src/common/k8s-api/create-kube-json-api.injectable.ts b/src/common/k8s-api/create-kube-json-api.injectable.ts new file mode 100644 index 0000000000..93de5a0d21 --- /dev/null +++ b/src/common/k8s-api/create-kube-json-api.injectable.ts @@ -0,0 +1,26 @@ +/** + * 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 type { RequestInit } from "node-fetch"; +import fetchInjectable from "../fetch/fetch.injectable"; +import loggerInjectable from "../logger.injectable"; +import type { JsonApiConfig, JsonApiDependencies } from "./json-api"; +import { KubeJsonApi } from "./kube-json-api"; + +export type CreateKubeJsonApi = (config: JsonApiConfig, reqInit?: RequestInit) => KubeJsonApi; + +const createKubeJsonApiInjectable = getInjectable({ + id: "create-kube-json-api", + instantiate: (di): CreateKubeJsonApi => { + const dependencies: JsonApiDependencies = { + fetch: di.inject(fetchInjectable), + logger: di.inject(loggerInjectable), + }; + + return (config, reqInit) => new KubeJsonApi(dependencies, config, reqInit); + }, +}); + +export default createKubeJsonApiInjectable; diff --git a/src/common/k8s-api/endpoints/cluster.api.ts b/src/common/k8s-api/endpoints/cluster.api.ts index 082f0adb09..aae4c239ad 100644 --- a/src/common/k8s-api/endpoints/cluster.api.ts +++ b/src/common/k8s-api/endpoints/cluster.api.ts @@ -3,8 +3,6 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { MetricData, IMetricsReqParams } from "./metrics.api"; -import { metricsApi } from "./metrics.api"; import { KubeObject } from "../kube-object"; import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; @@ -28,30 +26,6 @@ export class ClusterApi extends KubeApi { } } -export function getMetricsByNodeNames(nodeNames: string[], params?: IMetricsReqParams): Promise { - const nodes = nodeNames.join("|"); - const opts = { category: "cluster", nodes }; - - return metricsApi.getMetrics({ - memoryUsage: opts, - workloadMemoryUsage: opts, - memoryRequests: opts, - memoryLimits: opts, - memoryCapacity: opts, - memoryAllocatableCapacity: opts, - cpuUsage: opts, - cpuRequests: opts, - cpuLimits: opts, - cpuCapacity: opts, - cpuAllocatableCapacity: opts, - podUsage: opts, - podCapacity: opts, - podAllocatableCapacity: opts, - fsSize: opts, - fsUsage: opts, - }, params); -} - export enum ClusterStatus { ACTIVE = "Active", CREATING = "Creating", @@ -59,21 +33,6 @@ export enum ClusterStatus { ERROR = "Error", } -export interface ClusterMetricData extends Partial> { - memoryUsage: MetricData; - memoryRequests: MetricData; - memoryLimits: MetricData; - memoryCapacity: MetricData; - cpuUsage: MetricData; - cpuRequests: MetricData; - cpuLimits: MetricData; - cpuCapacity: MetricData; - podUsage: MetricData; - podCapacity: MetricData; - fsSize: MetricData; - fsUsage: MetricData; -} - export interface Cluster { spec: { clusterNetwork?: { diff --git a/src/common/k8s-api/endpoints/cron-job.api.injectable.ts b/src/common/k8s-api/endpoints/cron-job.api.injectable.ts index a22909861e..e2230ee2db 100644 --- a/src/common/k8s-api/endpoints/cron-job.api.injectable.ts +++ b/src/common/k8s-api/endpoints/cron-job.api.injectable.ts @@ -13,7 +13,9 @@ const cronJobApiInjectable = getInjectable({ instantiate: (di) => { assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "cronJobApi is only available in certain environments"); - return new CronJobApi(); + return new CronJobApi({ + checkPreferredVersion: true, + }); }, injectionToken: kubeApiInjectionToken, diff --git a/src/common/k8s-api/endpoints/cron-job.api.ts b/src/common/k8s-api/endpoints/cron-job.api.ts index 131b632589..7dad6a857d 100644 --- a/src/common/k8s-api/endpoints/cron-job.api.ts +++ b/src/common/k8s-api/endpoints/cron-job.api.ts @@ -73,7 +73,7 @@ export class CronJob extends KubeObject< > { static readonly kind = "CronJob"; static readonly namespaced = true; - static readonly apiBase = "/apis/batch/v1beta1/cronjobs"; + static readonly apiBase = "/apis/batch/v1/cronjobs"; getSuspendFlag() { return (this.spec.suspend ?? false).toString(); diff --git a/src/common/k8s-api/endpoints/daemon-set.api.ts b/src/common/k8s-api/endpoints/daemon-set.api.ts index f023652306..49719b04f9 100644 --- a/src/common/k8s-api/endpoints/daemon-set.api.ts +++ b/src/common/k8s-api/endpoints/daemon-set.api.ts @@ -5,8 +5,6 @@ import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import { metricsApi } from "./metrics.api"; -import type { PodMetricData } from "./pod.api"; import type { KubeObjectStatus, LabelSelector, NamespaceScopedMetadata } from "../kube-object"; import { KubeObject } from "../kube-object"; import type { PodTemplateSpec } from "./types/pod-template-spec"; @@ -90,20 +88,3 @@ export class DaemonSetApi extends KubeApi { }); } } - -export function getMetricsForDaemonSets(daemonsets: DaemonSet[], namespace: string, selector = ""): Promise { - const podSelector = daemonsets.map(daemonset => `${daemonset.getName()}-[[:alnum:]]{5}`).join("|"); - const opts = { category: "pods", pods: podSelector, namespace, selector }; - - return metricsApi.getMetrics({ - cpuUsage: opts, - memoryUsage: opts, - fsUsage: opts, - fsWrites: opts, - fsReads: opts, - networkReceive: opts, - networkTransmit: opts, - }, { - namespace, - }); -} diff --git a/src/common/k8s-api/endpoints/deployment.api.ts b/src/common/k8s-api/endpoints/deployment.api.ts index f67f26f70f..318ea8563f 100644 --- a/src/common/k8s-api/endpoints/deployment.api.ts +++ b/src/common/k8s-api/endpoints/deployment.api.ts @@ -7,8 +7,7 @@ import moment from "moment"; import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import { metricsApi } from "./metrics.api"; -import type { PodMetricData, PodSpec } from "./pod.api"; +import type { PodSpec } from "./pod.api"; import type { KubeObjectStatus, LabelSelector, NamespaceScopedMetadata } from "../kube-object"; import { KubeObject } from "../kube-object"; import { hasTypedProperty, isNumber, isObject } from "../../utils"; @@ -70,23 +69,6 @@ export class DeploymentApi extends KubeApi { } } -export function getMetricsForDeployments(deployments: Deployment[], namespace: string, selector = ""): Promise { - const podSelector = deployments.map(deployment => `${deployment.getName()}-[[:alnum:]]{9,}-[[:alnum:]]{5}`).join("|"); - const opts = { category: "pods", pods: podSelector, namespace, selector }; - - return metricsApi.getMetrics({ - cpuUsage: opts, - memoryUsage: opts, - fsUsage: opts, - fsWrites: opts, - fsReads: opts, - networkReceive: opts, - networkTransmit: opts, - }, { - namespace, - }); -} - export interface DeploymentSpec { replicas: number; selector: LabelSelector; diff --git a/src/common/k8s-api/endpoints/helm-charts.api.ts b/src/common/k8s-api/endpoints/helm-charts.api.ts index f4ba0cf45f..26da740830 100644 --- a/src/common/k8s-api/endpoints/helm-charts.api.ts +++ b/src/common/k8s-api/endpoints/helm-charts.api.ts @@ -3,72 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { compile } from "path-to-regexp"; -import { apiBase } from "../index"; -import { stringify } from "querystring"; -import type { RequestInit } from "node-fetch"; -import { autoBind, bifurcateArray, isDefined } from "../../utils"; +import { autoBind, bifurcateArray } from "../../utils"; import Joi from "joi"; -export type RepoHelmChartList = Record; - -export interface IHelmChartDetails { - readme: string; - versions: HelmChart[]; -} - -const endpoint = compile(`/v2/charts/:repo?/:name?`) as (params?: { - repo?: string; - name?: string; -}) => string; - -/** - * Get a list of all helm charts from all saved helm repos - */ -export async function listCharts(): Promise { - const data = await apiBase.get>(endpoint()); - - return Object - .values(data) - .reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), new Array()) - .map(([chart]) => HelmChart.create(chart, { onError: "log" })) - .filter(isDefined); -} - -export interface GetChartDetailsOptions { - version?: string; - reqInit?: RequestInit; -} - -/** - * Get the readme and all versions of a chart - * @param repo The repo to get from - * @param name The name of the chart to request the data of - * @param options.version The version of the chart's readme to get, default latest - * @param options.reqInit A way for passing in an abort controller or other browser request options - */ -export async function getChartDetails(repo: string, name: string, { version, reqInit }: GetChartDetailsOptions = {}): Promise { - const path = endpoint({ repo, name }); - - const { readme, ...data } = await apiBase.get(`${path}?${stringify({ version })}`, undefined, reqInit); - const versions = data.versions.map(version => HelmChart.create(version, { onError: "log" })).filter(isDefined); - - return { - readme, - versions, - }; -} - -/** - * Get chart values related to a specific repos' version of a chart - * @param repo The repo to get from - * @param name The name of the chart to request the data of - * @param version The version to get the values from - */ -export async function getChartValues(repo: string, name: string, version: string): Promise { - return apiBase.get(`/v2/charts/${repo}/${name}/values?${stringify({ version })}`); -} - export interface RawHelmChart { apiVersion: string; name: string; diff --git a/src/common/k8s-api/endpoints/helm-charts.api/request-charts.injectable.ts b/src/common/k8s-api/endpoints/helm-charts.api/request-charts.injectable.ts new file mode 100644 index 0000000000..89de5f9d17 --- /dev/null +++ b/src/common/k8s-api/endpoints/helm-charts.api/request-charts.injectable.ts @@ -0,0 +1,34 @@ +/** + * 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 { apiBaseInjectionToken } from "../../api-base"; +import type { RawHelmChart } from "../helm-charts.api"; +import { HelmChart } from "../helm-charts.api"; +import { isDefined } from "../../../utils"; + +export type RequestHelmCharts = () => Promise; +export type RepoHelmChartList = Record; + +/** + * Get a list of all helm charts from all saved helm repos + */ +const requestHelmChartsInjectable = getInjectable({ + id: "request-helm-charts", + instantiate: (di) => { + const apiBase = di.inject(apiBaseInjectionToken); + + return async () => { + const data = await apiBase.get>("/v2/charts"); + + return Object + .values(data) + .reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), new Array()) + .map(([chart]) => HelmChart.create(chart, { onError: "log" })) + .filter(isDefined); + }; + }, +}); + +export default requestHelmChartsInjectable; diff --git a/src/common/k8s-api/endpoints/helm-charts.api/request-readme.injectable.ts b/src/common/k8s-api/endpoints/helm-charts.api/request-readme.injectable.ts new file mode 100644 index 0000000000..c6815c4b93 --- /dev/null +++ b/src/common/k8s-api/endpoints/helm-charts.api/request-readme.injectable.ts @@ -0,0 +1,24 @@ +/** + * 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 { urlBuilderFor } from "../../../utils/buildUrl"; +import { apiBaseInjectionToken } from "../../api-base"; + +const requestReadmeEndpoint = urlBuilderFor("/v2/charts/:repo/:name/readme"); + +export type RequestHelmChartReadme = (repo: string, name: string, version?: string) => Promise; + +const requestHelmChartReadmeInjectable = getInjectable({ + id: "request-helm-chart-readme", + instantiate: (di): RequestHelmChartReadme => { + const apiBase = di.inject(apiBaseInjectionToken); + + return (repo, name, version) => ( + apiBase.get(requestReadmeEndpoint.compile({ name, repo }, { version })) + ); + }, +}); + +export default requestHelmChartReadmeInjectable; diff --git a/src/common/k8s-api/endpoints/helm-charts.api/request-values.injectable.ts b/src/common/k8s-api/endpoints/helm-charts.api/request-values.injectable.ts new file mode 100644 index 0000000000..ec927fc37a --- /dev/null +++ b/src/common/k8s-api/endpoints/helm-charts.api/request-values.injectable.ts @@ -0,0 +1,24 @@ +/** + * 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 { urlBuilderFor } from "../../../utils/buildUrl"; +import { apiBaseInjectionToken } from "../../api-base"; + +const requestValuesEndpoint = urlBuilderFor("/v2/charts/:repo/:name/values"); + +export type RequestHelmChartValues = (repo: string, name: string, version: string) => Promise; + +const requestHelmChartValuesInjectable = getInjectable({ + id: "request-helm-chart-values", + instantiate: (di): RequestHelmChartValues => { + const apiBase = di.inject(apiBaseInjectionToken); + + return (repo, name, version) => ( + apiBase.get(requestValuesEndpoint.compile({ repo, name }, { version })) + ); + }, +}); + +export default requestHelmChartValuesInjectable; diff --git a/src/common/k8s-api/endpoints/helm-charts.api/request-versions.injectable.ts b/src/common/k8s-api/endpoints/helm-charts.api/request-versions.injectable.ts new file mode 100644 index 0000000000..410d8ea596 --- /dev/null +++ b/src/common/k8s-api/endpoints/helm-charts.api/request-versions.injectable.ts @@ -0,0 +1,31 @@ +/** + * 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 { urlBuilderFor } from "../../../utils/buildUrl"; +import { apiBaseInjectionToken } from "../../api-base"; +import { HelmChart } from "../helm-charts.api"; +import type { RawHelmChart } from "../helm-charts.api"; +import { isDefined } from "../../../utils"; + +const requestVersionsEndpoint = urlBuilderFor("/v2/charts/:repo/:name/versions"); + +export type RequestHelmChartVersions = (repo: string, chartName: string) => Promise; + +const requestHelmChartVersionsInjectable = getInjectable({ + id: "request-helm-chart-versions", + instantiate: (di): RequestHelmChartVersions => { + const apiBase = di.inject(apiBaseInjectionToken); + + return async (repo, name) => { + const rawVersions = await apiBase.get(requestVersionsEndpoint.compile({ name, repo })) as RawHelmChart[]; + + return rawVersions + .map(version => HelmChart.create(version, { onError: "log" })) + .filter(isDefined); + }; + }, +}); + +export default requestHelmChartVersionsInjectable; diff --git a/src/common/k8s-api/endpoints/helm-releases.api.ts b/src/common/k8s-api/endpoints/helm-releases.api.ts index f9e11073c0..3460378e59 100644 --- a/src/common/k8s-api/endpoints/helm-releases.api.ts +++ b/src/common/k8s-api/endpoints/helm-releases.api.ts @@ -3,65 +3,14 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { apiBase } from "../index"; import type { ItemObject } from "../../item.store"; -import type { JsonApiData } from "../json-api"; -import { buildURLPositional } from "../../utils/buildUrl"; -import type { HelmReleaseDetails } from "../../../renderer/components/+helm-releases/release-details/release-details-model/call-for-helm-release/call-for-helm-release-details/call-for-helm-release-details.injectable"; +import type { HelmReleaseDetails } from "./helm-releases.api/request-details.injectable"; export interface HelmReleaseUpdateDetails { log: string; release: HelmReleaseDetails; } -export interface HelmReleaseRevision { - revision: number; - updated: string; - status: string; - chart: string; - app_version: string; - description: string; -} - -type EndpointParams = {} - | { namespace: string } - | { namespace: string; name: string } - | { namespace: string; name: string; route: string }; - -interface EndpointQuery { - all?: boolean; -} - -export const endpoint = buildURLPositional("/v2/releases/:namespace?/:name?/:route?"); - -export async function deleteRelease(name: string, namespace: string): Promise { - const path = endpoint({ name, namespace }); - - return apiBase.del(path); -} - -export async function getReleaseValues(name: string, namespace: string, all?: boolean): Promise { - const route = "values"; - const path = endpoint({ name, namespace, route }, { all }); - - return apiBase.get(path); -} - -export async function getReleaseHistory(name: string, namespace: string): Promise { - const route = "history"; - const path = endpoint({ name, namespace, route }); - - return apiBase.get(path); -} - -export async function rollbackRelease(name: string, namespace: string, revision: number): Promise { - const route = "rollback"; - const path = endpoint({ name, namespace, route }); - const data = { revision }; - - return apiBase.put(path, { data }); -} - export interface HelmReleaseDto { appVersion: string; name: string; diff --git a/src/common/k8s-api/endpoints/helm-releases.api/request-configuration.global-override-for-injectable.ts b/src/common/k8s-api/endpoints/helm-releases.api/request-configuration.global-override-for-injectable.ts new file mode 100644 index 0000000000..18b3bd0ec0 --- /dev/null +++ b/src/common/k8s-api/endpoints/helm-releases.api/request-configuration.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../../../test-utils/get-global-override"; +import requestHelmReleaseConfigurationInjectable from "./request-configuration.injectable"; + +export default getGlobalOverride(requestHelmReleaseConfigurationInjectable, () => () => { + throw new Error("Tried to call requestHelmReleaseConfiguration with no override"); +}); diff --git a/src/common/k8s-api/endpoints/helm-releases.api/request-configuration.injectable.ts b/src/common/k8s-api/endpoints/helm-releases.api/request-configuration.injectable.ts new file mode 100644 index 0000000000..1bfe168b11 --- /dev/null +++ b/src/common/k8s-api/endpoints/helm-releases.api/request-configuration.injectable.ts @@ -0,0 +1,29 @@ +/** + * 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 { urlBuilderFor } from "../../../utils/buildUrl"; +import { apiBaseInjectionToken } from "../../api-base"; + +export type RequestHelmReleaseConfiguration = ( + name: string, + namespace: string, + all: boolean +) => Promise; + +const requestConfigurationEnpoint = urlBuilderFor("/v2/releases/:namespace/:name/values"); + +const requestHelmReleaseConfigurationInjectable = getInjectable({ + id: "request-helm-release-configuration", + + instantiate: (di): RequestHelmReleaseConfiguration => { + const apiBase = di.inject(apiBaseInjectionToken); + + return (name, namespace, all: boolean) => ( + apiBase.get(requestConfigurationEnpoint.compile({ name, namespace }, { all })) + ); + }, +}); + +export default requestHelmReleaseConfigurationInjectable; diff --git a/src/common/k8s-api/endpoints/helm-releases.api/request-create.injectable.ts b/src/common/k8s-api/endpoints/helm-releases.api/request-create.injectable.ts new file mode 100644 index 0000000000..bad802d3cc --- /dev/null +++ b/src/common/k8s-api/endpoints/helm-releases.api/request-create.injectable.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import yaml from "js-yaml"; +import { getInjectable } from "@ogre-tools/injectable"; +import type { HelmReleaseUpdateDetails } from "../helm-releases.api"; +import { apiBaseInjectionToken } from "../../api-base"; +import { urlBuilderFor } from "../../../utils/buildUrl"; + +interface HelmReleaseCreatePayload { + name?: string; + repo: string; + chart: string; + namespace: string; + version: string; + values: string; +} + +export type RequestCreateHelmRelease = (payload: HelmReleaseCreatePayload) => Promise; + +const requestCreateEndpoint = urlBuilderFor("/v2/releases"); + +const requestCreateHelmReleaseInjectable = getInjectable({ + id: "request-create-helm-release", + + instantiate: (di): RequestCreateHelmRelease => { + const apiBase = di.inject(apiBaseInjectionToken); + + return ({ repo, chart, values, ...data }) => { + return apiBase.post(requestCreateEndpoint.compile({}), { + data: { + chart: `${repo}/${chart}`, + values: yaml.load(values), + ...data, + }, + }); + }; + }, +}); + +export default requestCreateHelmReleaseInjectable; diff --git a/src/common/k8s-api/endpoints/helm-releases.api/request-delete.injectable.ts b/src/common/k8s-api/endpoints/helm-releases.api/request-delete.injectable.ts new file mode 100644 index 0000000000..66b2013770 --- /dev/null +++ b/src/common/k8s-api/endpoints/helm-releases.api/request-delete.injectable.ts @@ -0,0 +1,22 @@ +/** + * 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 { urlBuilderFor } from "../../../utils/buildUrl"; +import { apiBaseInjectionToken } from "../../api-base"; + +export type RequestDeleteHelmRelease = (name: string, namespace: string) => Promise; + +const requestDeleteEndpoint = urlBuilderFor("/v2/releases/:namespace/:name"); + +const requestDeleteHelmReleaseInjectable = getInjectable({ + id: "request-delete-helm-release", + instantiate: (di): RequestDeleteHelmRelease => { + const apiBase = di.inject(apiBaseInjectionToken); + + return (name, namespace) => apiBase.del(requestDeleteEndpoint.compile({ name, namespace })); + }, +}); + +export default requestDeleteHelmReleaseInjectable; diff --git a/src/common/k8s-api/endpoints/helm-releases.api/request-details.injectable.ts b/src/common/k8s-api/endpoints/helm-releases.api/request-details.injectable.ts new file mode 100644 index 0000000000..0b712cd78e --- /dev/null +++ b/src/common/k8s-api/endpoints/helm-releases.api/request-details.injectable.ts @@ -0,0 +1,41 @@ +/** + * 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 type { KubeJsonApiData } from "../../kube-json-api"; +import { apiBaseInjectionToken } from "../../api-base"; +import { urlBuilderFor } from "../../../utils/buildUrl"; + +export interface HelmReleaseDetails { + resources: KubeJsonApiData[]; + name: string; + namespace: string; + version: string; + config: string; // release values + manifest: string; + info: { + deleted: string; + description: string; + first_deployed: string; + last_deployed: string; + notes: string; + status: string; + }; +} + +export type CallForHelmReleaseDetails = (name: string, namespace: string) => Promise; + +const requestDetailsEnpoint = urlBuilderFor("/v2/releases/:namespace/:name"); + +const requestHelmReleaseDetailsInjectable = getInjectable({ + id: "call-for-helm-release-details", + + instantiate: (di): CallForHelmReleaseDetails => { + const apiBase = di.inject(apiBaseInjectionToken); + + return (name, namespace) => apiBase.get(requestDetailsEnpoint.compile({ name, namespace })); + }, +}); + +export default requestHelmReleaseDetailsInjectable; diff --git a/src/common/k8s-api/endpoints/helm-releases.api/request-history.injectable.ts b/src/common/k8s-api/endpoints/helm-releases.api/request-history.injectable.ts new file mode 100644 index 0000000000..b6e9794fe7 --- /dev/null +++ b/src/common/k8s-api/endpoints/helm-releases.api/request-history.injectable.ts @@ -0,0 +1,31 @@ +/** + * 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 { urlBuilderFor } from "../../../utils/buildUrl"; +import { apiBaseInjectionToken } from "../../api-base"; + +export interface HelmReleaseRevision { + revision: number; + updated: string; + status: string; + chart: string; + app_version: string; + description: string; +} + +export type RequestHelmReleaseHistory = (name: string, namespace: string) => Promise; + +const requestHistoryEnpoint = urlBuilderFor("/v2/releases/:namespace/:name/history"); + +const requestHelmReleaseHistoryInjectable = getInjectable({ + id: "request-helm-release-history", + instantiate: (di): RequestHelmReleaseHistory => { + const apiBase = di.inject(apiBaseInjectionToken); + + return (name, namespace) => apiBase.get(requestHistoryEnpoint.compile({ name, namespace })); + }, +}); + +export default requestHelmReleaseHistoryInjectable; diff --git a/src/common/k8s-api/endpoints/helm-releases.api/request-releases.injectable.ts b/src/common/k8s-api/endpoints/helm-releases.api/request-releases.injectable.ts new file mode 100644 index 0000000000..2619e289c6 --- /dev/null +++ b/src/common/k8s-api/endpoints/helm-releases.api/request-releases.injectable.ts @@ -0,0 +1,24 @@ +/** + * 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 { urlBuilderFor } from "../../../utils/buildUrl"; +import { apiBaseInjectionToken } from "../../api-base"; +import type { HelmReleaseDto } from "../helm-releases.api"; + +export type RequestHelmReleases = (namespace?: string) => Promise; + +const requestHelmReleasesEndpoint = urlBuilderFor("/v2/releases/:namespace?"); + +const requestHelmReleasesInjectable = getInjectable({ + id: "request-helm-releases", + + instantiate: (di): RequestHelmReleases => { + const apiBase = di.inject(apiBaseInjectionToken); + + return (namespace) => apiBase.get(requestHelmReleasesEndpoint.compile({ namespace })); + }, +}); + +export default requestHelmReleasesInjectable; diff --git a/src/common/k8s-api/endpoints/helm-releases.api/request-rollback.injectable.ts b/src/common/k8s-api/endpoints/helm-releases.api/request-rollback.injectable.ts new file mode 100644 index 0000000000..1a9229b73a --- /dev/null +++ b/src/common/k8s-api/endpoints/helm-releases.api/request-rollback.injectable.ts @@ -0,0 +1,27 @@ +/** + * 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 { urlBuilderFor } from "../../../utils/buildUrl"; +import { apiBaseInjectionToken } from "../../api-base"; + +export type RequestHelmReleaseRollback = (name: string, namespace: string, revision: number) => Promise; + +const requestRollbackEndpoint = urlBuilderFor("/v2/releases/:namespace/:name"); + +const requestHelmReleaseRollbackInjectable = getInjectable({ + id: "request-helm-release-rollback", + instantiate: (di): RequestHelmReleaseRollback => { + const apiBase = di.inject(apiBaseInjectionToken); + + return async (name, namespace, revision) => { + await apiBase.put( + requestRollbackEndpoint.compile({ name, namespace }), + { data: { revision }}, + ); + }; + }, +}); + +export default requestHelmReleaseRollbackInjectable; diff --git a/src/common/k8s-api/endpoints/helm-releases.api/request-update.injectable.ts b/src/common/k8s-api/endpoints/helm-releases.api/request-update.injectable.ts new file mode 100644 index 0000000000..8f46a3a210 --- /dev/null +++ b/src/common/k8s-api/endpoints/helm-releases.api/request-update.injectable.ts @@ -0,0 +1,49 @@ +/** + * 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 { apiBaseInjectionToken } from "../../api-base"; +import { urlBuilderFor } from "../../../utils/buildUrl"; +import type { AsyncResult } from "../../../utils/async-result"; + +interface HelmReleaseUpdatePayload { + repo: string; + chart: string; + version: string; + values: string; +} + +export type RequestHelmReleaseUpdate = ( + name: string, + namespace: string, + payload: HelmReleaseUpdatePayload +) => Promise>; + +const requestUpdateEndpoint = urlBuilderFor("/v2/releases/:namespace/:name"); + +const requestHelmReleaseUpdateInjectable = getInjectable({ + id: "request-helm-release-update", + + instantiate: (di): RequestHelmReleaseUpdate => { + const apiBase = di.inject(apiBaseInjectionToken); + + return async (name, namespace, { repo, chart, values, version }) => { + try { + await apiBase.put(requestUpdateEndpoint.compile({ name, namespace }), { + data: { + chart: `${repo}/${chart}`, + values, + version, + }, + }); + } catch (e) { + return { callWasSuccessful: false, error: e }; + } + + return { callWasSuccessful: true }; + }; + }, +}); + +export default requestHelmReleaseUpdateInjectable; diff --git a/src/common/k8s-api/endpoints/helm-releases.api/request-values.injectable.ts b/src/common/k8s-api/endpoints/helm-releases.api/request-values.injectable.ts new file mode 100644 index 0000000000..d30f9973c4 --- /dev/null +++ b/src/common/k8s-api/endpoints/helm-releases.api/request-values.injectable.ts @@ -0,0 +1,22 @@ +/** + * 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 { urlBuilderFor } from "../../../utils/buildUrl"; +import { apiBaseInjectionToken } from "../../api-base"; + +export type RequestHelmReleaseValues = (name: string, namespace: string, all?: boolean) => Promise; + +const requestValuesEndpoint = urlBuilderFor("/v2/release/:namespace/:name/values"); + +const requestHelmReleaseValuesInjectable = getInjectable({ + id: "request-helm-release-values", + instantiate: (di): RequestHelmReleaseValues => { + const apiBase = di.inject(apiBaseInjectionToken); + + return (name, namespace, all) => apiBase.get(requestValuesEndpoint.compile({ name, namespace }, { all })); + }, +}); + +export default requestHelmReleaseValuesInjectable; diff --git a/src/common/k8s-api/endpoints/index.ts b/src/common/k8s-api/endpoints/index.ts index 0e2e677521..c0493e6370 100644 --- a/src/common/k8s-api/endpoints/index.ts +++ b/src/common/k8s-api/endpoints/index.ts @@ -19,6 +19,7 @@ export * from "./events.api"; export * from "./horizontal-pod-autoscaler.api"; export * from "./ingress.api"; export * from "./job.api"; +export * from "./lease.api"; export * from "./limit-range.api"; export * from "./namespace.api"; export * from "./network-policy.api"; @@ -34,6 +35,7 @@ export * from "./replica-set.api"; export * from "./resource-quota.api"; export * from "./role.api"; export * from "./role-binding.api"; +export * from "./runtime-class.api"; export * from "./secret.api"; export * from "./self-subject-rules-reviews.api"; export * from "./service.api"; diff --git a/src/common/k8s-api/endpoints/ingress.api.ts b/src/common/k8s-api/endpoints/ingress.api.ts index 84910d2783..d223530013 100644 --- a/src/common/k8s-api/endpoints/ingress.api.ts +++ b/src/common/k8s-api/endpoints/ingress.api.ts @@ -6,8 +6,6 @@ import type { NamespaceScopedMetadata, TypedLocalObjectReference } from "../kube-object"; import { KubeObject } from "../kube-object"; import { hasTypedProperty, isString, iter } from "../../utils"; -import type { MetricData } from "./metrics.api"; -import { metricsApi } from "./metrics.api"; import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import type { RequireExactlyOne } from "type-fest"; @@ -24,26 +22,6 @@ export class IngressApi extends KubeApi { } } -export function getMetricsForIngress(ingress: string, namespace: string): Promise { - const opts = { category: "ingress", ingress, namespace }; - - return metricsApi.getMetrics({ - bytesSentSuccess: opts, - bytesSentFailure: opts, - requestDurationSeconds: opts, - responseDurationSeconds: opts, - }, { - namespace, - }); -} - -export interface IngressMetricData extends Partial> { - bytesSentSuccess: MetricData; - bytesSentFailure: MetricData; - requestDurationSeconds: MetricData; - responseDurationSeconds: MetricData; -} - export interface ILoadBalancerIngress { hostname?: string; ip?: string; diff --git a/src/common/k8s-api/endpoints/job.api.injectable.ts b/src/common/k8s-api/endpoints/job.api.injectable.ts index a9c4252e59..fc25c8c61f 100644 --- a/src/common/k8s-api/endpoints/job.api.injectable.ts +++ b/src/common/k8s-api/endpoints/job.api.injectable.ts @@ -13,7 +13,9 @@ const jobApiInjectable = getInjectable({ instantiate: (di) => { assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "jobApi is only available in certain environments"); - return new JobApi(); + return new JobApi({ + checkPreferredVersion: true, + }); }, injectionToken: kubeApiInjectionToken, diff --git a/src/common/k8s-api/endpoints/job.api.ts b/src/common/k8s-api/endpoints/job.api.ts index 84e166ecc4..66f4ef3768 100644 --- a/src/common/k8s-api/endpoints/job.api.ts +++ b/src/common/k8s-api/endpoints/job.api.ts @@ -5,8 +5,7 @@ import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import { metricsApi } from "./metrics.api"; -import type { PodMetricData, PodSpec } from "./pod.api"; +import type { PodSpec } from "./pod.api"; import type { Container } from "./types/container"; import type { KubeObjectStatus, LabelSelector, NamespaceScopedMetadata } from "../kube-object"; import { KubeObject } from "../kube-object"; @@ -103,20 +102,3 @@ export class JobApi extends KubeApi { }); } } - -export function getMetricsForJobs(jobs: Job[], namespace: string, selector = ""): Promise { - const podSelector = jobs.map(job => `${job.getName()}-[[:alnum:]]{5}`).join("|"); - const opts = { category: "pods", pods: podSelector, namespace, selector }; - - return metricsApi.getMetrics({ - cpuUsage: opts, - memoryUsage: opts, - fsUsage: opts, - fsWrites: opts, - fsReads: opts, - networkReceive: opts, - networkTransmit: opts, - }, { - namespace, - }); -} diff --git a/src/common/k8s-api/endpoints/lease.api.injectable.ts b/src/common/k8s-api/endpoints/lease.api.injectable.ts new file mode 100644 index 0000000000..41bd5e3935 --- /dev/null +++ b/src/common/k8s-api/endpoints/lease.api.injectable.ts @@ -0,0 +1,22 @@ +/** + * 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 assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { LeaseApi } from "./lease.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; + +const leaseApiInjectable = getInjectable({ + id: "lease-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "leaseApi is only available in certain environments"); + + return new LeaseApi(); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default leaseApiInjectable; diff --git a/src/common/k8s-api/endpoints/lease.api.ts b/src/common/k8s-api/endpoints/lease.api.ts new file mode 100644 index 0000000000..22cfb778e4 --- /dev/null +++ b/src/common/k8s-api/endpoints/lease.api.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { NamespaceScopedMetadata } from "../kube-object"; +import { KubeObject } from "../kube-object"; + +export interface LeaseSpec { + acquireTime?: string; + holderIdentity: string; + leaseDurationSeconds: number; + leaseTransitions?: number; + renewTime: string; +} + +export class Lease extends KubeObject< + NamespaceScopedMetadata, + void, + LeaseSpec +> { + static readonly kind = "Lease"; + static readonly namespaced = true; + static readonly apiBase = "/apis/coordination.k8s.io/v1/leases"; + + getAcquireTime(): string { + return this.spec.acquireTime || ""; + } + + getHolderIdentity(): string { + return this.spec.holderIdentity; + } + + getLeaseDurationSeconds(): number { + return this.spec.leaseDurationSeconds; + } + + getLeaseTransitions(): number | undefined { + return this.spec.leaseTransitions; + } + + getRenewTime(): string { + return this.spec.renewTime; + } +} + +export class LeaseApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: Lease, + }); + } +} diff --git a/src/common/k8s-api/endpoints/metrics.api.ts b/src/common/k8s-api/endpoints/metrics.api.ts index b5469e8a24..406ab1d0b2 100644 --- a/src/common/k8s-api/endpoints/metrics.api.ts +++ b/src/common/k8s-api/endpoints/metrics.api.ts @@ -6,7 +6,7 @@ // Metrics api import moment from "moment"; -import { apiBase } from "../index"; +import { isDefined, object } from "../../utils"; export interface MetricData { status: string; @@ -29,63 +29,6 @@ export interface MetricResult { values: [number, string][]; } -export interface MetricProviderInfo { - name: string; - id: string; - isConfigurable: boolean; -} - -export interface IMetricsReqParams { - start?: number | string; // timestamp in seconds or valid date-string - end?: number | string; - step?: number; // step in seconds (default: 60s = each point 1m) - range?: number; // time-range in seconds for data aggregation (default: 3600s = last 1h) - namespace?: string; // rbac-proxy validation param -} - -export interface IResourceMetrics { - [metric: string]: T; - cpuUsage: T; - memoryUsage: T; - fsUsage: T; - fsWrites: T; - fsReads: T; - networkReceive: T; - networkTransmit: T; -} - -async function getMetrics(query: string, reqParams?: IMetricsReqParams): Promise; -async function getMetrics(query: string[], reqParams?: IMetricsReqParams): Promise; -async function getMetrics(query: Record>>, reqParams?: IMetricsReqParams): Promise>; - -async function getMetrics(query: string | string[] | Partial>>>, reqParams: IMetricsReqParams = {}): Promise>> { - const { range = 3600, step = 60, namespace } = reqParams; - let { start, end } = reqParams; - - if (!start && !end) { - const timeNow = Date.now() / 1000; - const now = moment.unix(timeNow).startOf("minute").unix(); // round date to minutes - - start = now - range; - end = now; - } - - return apiBase.post("/metrics", { - data: query, - query: { - start, end, step, - "kubernetes_namespace": namespace, - }, - }); -} - -export const metricsApi = { - getMetrics, - async getMetricProviders(): Promise { - return apiBase.get("/metrics/providers"); - }, -}; - export function normalizeMetrics(metrics: MetricData | undefined | null, frames = 60): MetricData { if (!metrics?.data?.result) { return { @@ -145,7 +88,7 @@ export function isMetricsEmpty(metrics: Partial>) { return Object.values(metrics).every(metric => !metric?.data?.result?.length); } -export function getItemMetrics(metrics: Partial> | null | undefined, itemName: string): Partial> | undefined { +export function getItemMetrics(metrics: Partial> | null | undefined, itemName: string): Partial> | undefined { if (!metrics) { return undefined; } @@ -166,22 +109,16 @@ export function getItemMetrics(metrics: Partial> | nu return itemMetrics; } -export function getMetricLastPoints>>(metrics: T): Record { - const result: Partial<{ [metric: string]: number }> = {}; - - Object.keys(metrics).forEach(metricName => { - try { - const metric = metrics[metricName]; - - if (metric?.data.result.length) { - result[metricName] = +metric.data.result[0].values.slice(-1)[0][1]; - } - } catch { - // ignore error - } - - return result; - }, {}); - - return result as Record; +export function getMetricLastPoints(metrics: Partial>): Partial> { + return object.fromEntries( + object.entries(metrics) + .map(([metricName, metric]) => { + try { + return [metricName, +metric.data.result[0].values.slice(-1)[0][1]] as const; + } catch { + return undefined; + } + }) + .filter(isDefined), + ); } diff --git a/src/common/k8s-api/endpoints/metrics.api/request-cluster-metrics-by-node-names.injectable.ts b/src/common/k8s-api/endpoints/metrics.api/request-cluster-metrics-by-node-names.injectable.ts new file mode 100644 index 0000000000..84268e3f6a --- /dev/null +++ b/src/common/k8s-api/endpoints/metrics.api/request-cluster-metrics-by-node-names.injectable.ts @@ -0,0 +1,63 @@ +/** + * 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 type { MetricData } from "../metrics.api"; +import type { RequestMetricsParams } from "./request-metrics.injectable"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface ClusterMetricData { + memoryUsage: MetricData; + memoryRequests: MetricData; + memoryLimits: MetricData; + memoryCapacity: MetricData; + memoryAllocatableCapacity: MetricData; + cpuUsage: MetricData; + cpuRequests: MetricData; + cpuLimits: MetricData; + cpuCapacity: MetricData; + cpuAllocatableCapacity: MetricData; + podUsage: MetricData; + podCapacity: MetricData; + podAllocatableCapacity: MetricData; + fsSize: MetricData; + fsUsage: MetricData; +} + +export type RequestClusterMetricsByNodeNames = (nodeNames: string[], params?: RequestMetricsParams) => Promise; + +const requestClusterMetricsByNodeNamesInjectable = getInjectable({ + id: "get-cluster-metrics-by-node-names", + instantiate: (di): RequestClusterMetricsByNodeNames => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return (nodeNames, params) => { + const opts = { + category: "cluster", + nodes: nodeNames.join("|"), + }; + + return requestMetrics({ + memoryUsage: opts, + workloadMemoryUsage: opts, + memoryRequests: opts, + memoryLimits: opts, + memoryCapacity: opts, + memoryAllocatableCapacity: opts, + cpuUsage: opts, + cpuRequests: opts, + cpuLimits: opts, + cpuCapacity: opts, + cpuAllocatableCapacity: opts, + podUsage: opts, + podCapacity: opts, + podAllocatableCapacity: opts, + fsSize: opts, + fsUsage: opts, + }, params); + }; + }, +}); + +export default requestClusterMetricsByNodeNamesInjectable; diff --git a/src/common/k8s-api/endpoints/metrics.api/request-ingress-metrics.injectable.ts b/src/common/k8s-api/endpoints/metrics.api/request-ingress-metrics.injectable.ts new file mode 100644 index 0000000000..8167a6bef4 --- /dev/null +++ b/src/common/k8s-api/endpoints/metrics.api/request-ingress-metrics.injectable.ts @@ -0,0 +1,38 @@ +/** + * 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 type { MetricData } from "../metrics.api"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface IngressMetricData { + bytesSentSuccess: MetricData; + bytesSentFailure: MetricData; + requestDurationSeconds: MetricData; + responseDurationSeconds: MetricData; +} + +export type RequestIngressMetrics = (ingress: string, namespace: string) => Promise; + +const requestIngressMetricsInjectable = getInjectable({ + id: "request-ingress-metrics", + instantiate: (di): RequestIngressMetrics => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return (ingress, namespace) => { + const opts = { category: "ingress", ingress, namespace }; + + return requestMetrics({ + bytesSentSuccess: opts, + bytesSentFailure: opts, + requestDurationSeconds: opts, + responseDurationSeconds: opts, + }, { + namespace, + }); + }; + }, +}); + +export default requestIngressMetricsInjectable; diff --git a/src/common/k8s-api/endpoints/metrics.api/request-metrics-for-all-nodes.injectable.ts b/src/common/k8s-api/endpoints/metrics.api/request-metrics-for-all-nodes.injectable.ts new file mode 100644 index 0000000000..6789d6debc --- /dev/null +++ b/src/common/k8s-api/endpoints/metrics.api/request-metrics-for-all-nodes.injectable.ts @@ -0,0 +1,44 @@ +/** + * 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 type { MetricData } from "../metrics.api"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface NodeMetricData { + memoryUsage: MetricData; + workloadMemoryUsage: MetricData; + memoryCapacity: MetricData; + memoryAllocatableCapacity: MetricData; + cpuUsage: MetricData; + cpuCapacity: MetricData; + fsUsage: MetricData; + fsSize: MetricData; +} + +export type RequestAllNodeMetrics = () => Promise; + +const requestAllNodeMetricsInjectable = getInjectable({ + id: "request-all-node-metrics", + instantiate: (di): RequestAllNodeMetrics => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return () => { + const opts = { category: "nodes" }; + + return requestMetrics({ + memoryUsage: opts, + workloadMemoryUsage: opts, + memoryCapacity: opts, + memoryAllocatableCapacity: opts, + cpuUsage: opts, + cpuCapacity: opts, + fsSize: opts, + fsUsage: opts, + }); + }; + }, +}); + +export default requestAllNodeMetricsInjectable; diff --git a/src/common/k8s-api/endpoints/metrics.api/request-metrics.injectable.ts b/src/common/k8s-api/endpoints/metrics.api/request-metrics.injectable.ts new file mode 100644 index 0000000000..0da0bc95ec --- /dev/null +++ b/src/common/k8s-api/endpoints/metrics.api/request-metrics.injectable.ts @@ -0,0 +1,73 @@ +/** + * 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 { getSecondsFromUnixEpoch } from "../../../utils/date/get-current-date-time"; +import { apiBaseInjectionToken } from "../../api-base"; +import type { MetricData } from "../metrics.api"; + + +export interface RequestMetricsParams { + /** + * timestamp in seconds or valid date-string + */ + start?: number | string; + + /** + * timestamp in seconds or valid date-string + */ + end?: number | string; + + /** + * step in seconds + * @default 60 (1 minute) + */ + step?: number; + + /** + * time-range in seconds for data aggregation + * @default 3600 (1 hour) + */ + range?: number; + + /** + * rbac-proxy validation param + */ + namespace?: string; +} + +export interface RequestMetrics { + (query: string, params?: RequestMetricsParams): Promise; + (query: string[], params?: RequestMetricsParams): Promise; + (query: Record>>, params?: RequestMetricsParams): Promise>; +} + +const requestMetricsInjectable = getInjectable({ + id: "request-metrics", + instantiate: (di) => { + const apiBase = di.inject(apiBaseInjectionToken); + + return (async (query: object, params: RequestMetricsParams = {}) => { + const { range = 3600, step = 60, namespace } = params; + let { start, end } = params; + + if (!start && !end) { + const now = getSecondsFromUnixEpoch(); + + start = now - range; + end = now; + } + + return apiBase.post("/metrics", { + data: query, + query: { + start, end, step, + "kubernetes_namespace": namespace, + }, + }); + }) as RequestMetrics; + }, +}); + +export default requestMetricsInjectable; diff --git a/src/common/k8s-api/endpoints/metrics.api/request-persistent-volume-claim-metrics.injectable.ts b/src/common/k8s-api/endpoints/metrics.api/request-persistent-volume-claim-metrics.injectable.ts new file mode 100644 index 0000000000..5cafb5ade0 --- /dev/null +++ b/src/common/k8s-api/endpoints/metrics.api/request-persistent-volume-claim-metrics.injectable.ts @@ -0,0 +1,35 @@ +/** + * 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 type { MetricData } from "../metrics.api"; +import type { PersistentVolumeClaim } from "../persistent-volume-claim.api"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface PersistentVolumeClaimMetricData { + diskUsage: MetricData; + diskCapacity: MetricData; +} + +export type RequestPersistentVolumeClaimMetrics = (claim: PersistentVolumeClaim) => Promise; + +const requestPersistentVolumeClaimMetricsInjectable = getInjectable({ + id: "request-persistent-volume-claim-metrics", + instantiate: (di): RequestPersistentVolumeClaimMetrics => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return (claim) => { + const opts = { category: "pvc", pvc: claim.getName(), namespace: claim.getNs() }; + + return requestMetrics({ + diskUsage: opts, + diskCapacity: opts, + }, { + namespace: opts.namespace, + }); + }; + }, +}); + +export default requestPersistentVolumeClaimMetricsInjectable; diff --git a/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-daemon-sets.injectable.ts b/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-daemon-sets.injectable.ts new file mode 100644 index 0000000000..d5653574ff --- /dev/null +++ b/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-daemon-sets.injectable.ts @@ -0,0 +1,46 @@ +/** + * 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 type { DaemonSet } from "../daemon-set.api"; +import type { MetricData } from "../metrics.api"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface DaemonSetPodMetricData { + cpuUsage: MetricData; + memoryUsage: MetricData; + fsUsage: MetricData; + fsWrites: MetricData; + fsReads: MetricData; + networkReceive: MetricData; + networkTransmit: MetricData; +} + +export type RequestPodMetricsForDaemonSets = (daemonsets: DaemonSet[], namespace: string, selector?: string) => Promise; + +const requestPodMetricsForDaemonSetsInjectable = getInjectable({ + id: "request-pod-metrics-for-daemon-sets", + instantiate: (di): RequestPodMetricsForDaemonSets => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return (daemonSets, namespace, selector = "") => { + const podSelector = daemonSets.map(daemonSet => `${daemonSet.getName()}-[[:alnum:]]{5}`).join("|"); + const opts = { category: "pods", pods: podSelector, namespace, selector }; + + return requestMetrics({ + cpuUsage: opts, + memoryUsage: opts, + fsUsage: opts, + fsWrites: opts, + fsReads: opts, + networkReceive: opts, + networkTransmit: opts, + }, { + namespace, + }); + }; + }, +}); + +export default requestPodMetricsForDaemonSetsInjectable; diff --git a/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-deployments.injectable.ts b/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-deployments.injectable.ts new file mode 100644 index 0000000000..ced8ccd7fe --- /dev/null +++ b/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-deployments.injectable.ts @@ -0,0 +1,46 @@ +/** + * 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 type { Deployment } from "../deployment.api"; +import type { MetricData } from "../metrics.api"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface DeploymentPodMetricData { + cpuUsage: MetricData; + memoryUsage: MetricData; + fsUsage: MetricData; + fsWrites: MetricData; + fsReads: MetricData; + networkReceive: MetricData; + networkTransmit: MetricData; +} + +export type RequestPodMetricsForDeployments = (deployments: Deployment[], namespace: string, selector?: string) => Promise; + +const requestPodMetricsForDeploymentsInjectable = getInjectable({ + id: "request-pod-metrics-for-deployments", + instantiate: (di): RequestPodMetricsForDeployments => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return (deployments, namespace, selector = "") => { + const podSelector = deployments.map(deployment => `${deployment.getName()}-[[:alnum:]]{9,}-[[:alnum:]]{5}`).join("|"); + const opts = { category: "pods", pods: podSelector, namespace, selector }; + + return requestMetrics({ + cpuUsage: opts, + memoryUsage: opts, + fsUsage: opts, + fsWrites: opts, + fsReads: opts, + networkReceive: opts, + networkTransmit: opts, + }, { + namespace, + }); + }; + }, +}); + +export default requestPodMetricsForDeploymentsInjectable; diff --git a/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-jobs.injectable.ts b/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-jobs.injectable.ts new file mode 100644 index 0000000000..a812fae59c --- /dev/null +++ b/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-jobs.injectable.ts @@ -0,0 +1,46 @@ +/** + * 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 type { Job } from "../job.api"; +import type { MetricData } from "../metrics.api"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface JobPodMetricData { + cpuUsage: MetricData; + memoryUsage: MetricData; + fsUsage: MetricData; + fsWrites: MetricData; + fsReads: MetricData; + networkReceive: MetricData; + networkTransmit: MetricData; +} + +export type RequestPodMetricsForJobs = (jobs: Job[], namespace: string, selector?: string) => Promise; + +const requestPodMetricsForJobsInjectable = getInjectable({ + id: "request-pod-metrics-for-jobs", + instantiate: (di): RequestPodMetricsForJobs => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return (jobs, namespace, selector) => { + const podSelector = jobs.map(job => `${job.getName()}-[[:alnum:]]{5}`).join("|"); + const opts = { category: "pods", pods: podSelector, namespace, selector }; + + return requestMetrics({ + cpuUsage: opts, + memoryUsage: opts, + fsUsage: opts, + fsWrites: opts, + fsReads: opts, + networkReceive: opts, + networkTransmit: opts, + }, { + namespace, + }); + }; + }, +}); + +export default requestPodMetricsForJobsInjectable; diff --git a/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-replica-sets.injectable.ts b/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-replica-sets.injectable.ts new file mode 100644 index 0000000000..4186cba7b4 --- /dev/null +++ b/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-replica-sets.injectable.ts @@ -0,0 +1,46 @@ +/** + * 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 type { MetricData } from "../metrics.api"; +import type { ReplicaSet } from "../replica-set.api"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface ReplicaSetPodMetricData { + cpuUsage: MetricData; + memoryUsage: MetricData; + fsUsage: MetricData; + fsWrites: MetricData; + fsReads: MetricData; + networkReceive: MetricData; + networkTransmit: MetricData; +} + +export type RequestPodMetricsForReplicaSets = (replicaSets: ReplicaSet[], namespace: string, selector?: string) => Promise; + +const requestPodMetricsForReplicaSetsInjectable = getInjectable({ + id: "request-pod-metrics-for-replica-sets", + instantiate: (di): RequestPodMetricsForReplicaSets => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return (replicaSets, namespace, selector = "") => { + const podSelector = replicaSets.map(replicaSet => `${replicaSet.getName()}-[[:alnum:]]{5}`).join("|"); + const opts = { category: "pods", pods: podSelector, namespace, selector }; + + return requestMetrics({ + cpuUsage: opts, + memoryUsage: opts, + fsUsage: opts, + fsWrites: opts, + fsReads: opts, + networkReceive: opts, + networkTransmit: opts, + }, { + namespace, + }); + }; + }, +}); + +export default requestPodMetricsForReplicaSetsInjectable; diff --git a/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-stateful-sets.injectable.ts b/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-stateful-sets.injectable.ts new file mode 100644 index 0000000000..227961bc02 --- /dev/null +++ b/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-stateful-sets.injectable.ts @@ -0,0 +1,47 @@ +/** + * 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 type { MetricData } from "../metrics.api"; +import type { StatefulSet } from "../stateful-set.api"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface StatefulSetPodMetricData { + cpuUsage: MetricData; + memoryUsage: MetricData; + fsUsage: MetricData; + fsWrites: MetricData; + fsReads: MetricData; + networkReceive: MetricData; + networkTransmit: MetricData; +} + +export type RequestPodMetricsForStatefulSets = (statefulSets: StatefulSet[], namespace: string, selector?: string) => Promise; + +const requestPodMetricsForStatefulSetsInjectable = getInjectable({ + id: "request-pod-metrics-for-stateful-sets", + instantiate: (di): RequestPodMetricsForStatefulSets => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return (statefulSets, namespace, selector = "") => { + const podSelector = statefulSets.map(statefulset => `${statefulset.getName()}-[[:digit:]]+`).join("|"); + const opts = { category: "pods", pods: podSelector, namespace, selector }; + + return requestMetrics({ + cpuUsage: opts, + memoryUsage: opts, + fsUsage: opts, + fsWrites: opts, + fsReads: opts, + networkReceive: opts, + networkTransmit: opts, + }, { + namespace, + }); + }; + }, +}); + +export default requestPodMetricsForStatefulSetsInjectable; + diff --git a/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-in-namespace.injectable.ts b/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-in-namespace.injectable.ts new file mode 100644 index 0000000000..872ffd740e --- /dev/null +++ b/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-in-namespace.injectable.ts @@ -0,0 +1,44 @@ +/** + * 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 type { MetricData } from "../metrics.api"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface PodMetricInNamespaceData { + cpuUsage: MetricData; + memoryUsage: MetricData; + fsUsage: MetricData; + fsWrites: MetricData; + fsReads: MetricData; + networkReceive: MetricData; + networkTransmit: MetricData; +} + +export type RequestPodMetricsInNamespace = (namespace: string, selector?: string) => Promise; + +const requestPodMetricsInNamespaceInjectable = getInjectable({ + id: "request-pod-metrics-in-namespace", + instantiate: (di): RequestPodMetricsInNamespace => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return (namespace, selector) => { + const opts = { category: "pods", pods: ".*", namespace, selector }; + + return requestMetrics({ + cpuUsage: opts, + memoryUsage: opts, + fsUsage: opts, + fsWrites: opts, + fsReads: opts, + networkReceive: opts, + networkTransmit: opts, + }, { + namespace, + }); + }; + }, +}); + +export default requestPodMetricsInNamespaceInjectable; diff --git a/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics.injectable.ts b/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics.injectable.ts new file mode 100644 index 0000000000..e14e5dc293 --- /dev/null +++ b/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics.injectable.ts @@ -0,0 +1,54 @@ +/** + * 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 type { MetricData } from "../metrics.api"; +import type { Pod } from "../pod.api"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface PodMetricData { + cpuUsage: MetricData; + memoryUsage: MetricData; + fsUsage: MetricData; + fsWrites: MetricData; + fsReads: MetricData; + networkReceive: MetricData; + networkTransmit: MetricData; + cpuRequests: MetricData; + cpuLimits: MetricData; + memoryRequests: MetricData; + memoryLimits: MetricData; +} + +export type RequestPodMetrics = (pods: Pod[], namespace: string, selector?: string) => Promise; + +const requestPodMetricsInjectable = getInjectable({ + id: "request-pod-metrics", + instantiate: (di): RequestPodMetrics => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return (pods, namespace, selector = "pod, namespace") => { + const podSelector = pods.map(pod => pod.getName()).join("|"); + const opts = { category: "pods", pods: podSelector, namespace, selector }; + + return requestMetrics({ + cpuUsage: opts, + cpuRequests: opts, + cpuLimits: opts, + memoryUsage: opts, + memoryRequests: opts, + memoryLimits: opts, + fsUsage: opts, + fsWrites: opts, + fsReads: opts, + networkReceive: opts, + networkTransmit: opts, + }, { + namespace, + }); + }; + }, +}); + +export default requestPodMetricsInjectable; diff --git a/src/common/k8s-api/endpoints/metrics.api/request-providers.injectable.ts b/src/common/k8s-api/endpoints/metrics.api/request-providers.injectable.ts new file mode 100644 index 0000000000..4711333ca6 --- /dev/null +++ b/src/common/k8s-api/endpoints/metrics.api/request-providers.injectable.ts @@ -0,0 +1,26 @@ +/** + * 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 { apiBaseInjectionToken } from "../../api-base"; + +export interface MetricProviderInfo { + name: string; + id: string; + isConfigurable: boolean; +} + +export type RequestMetricsProviders = () => Promise; + +const requestMetricsProvidersInjectable = getInjectable({ + id: "request-metrics-providers", + instantiate: (di): RequestMetricsProviders => { + const apiBase = di.inject(apiBaseInjectionToken); + + return () => apiBase.get("/metrics/providers"); + }, +}); + +export default requestMetricsProvidersInjectable; diff --git a/src/common/k8s-api/endpoints/namespace.api.ts b/src/common/k8s-api/endpoints/namespace.api.ts index 6a2c7e2a95..774d91cf61 100644 --- a/src/common/k8s-api/endpoints/namespace.api.ts +++ b/src/common/k8s-api/endpoints/namespace.api.ts @@ -7,8 +7,6 @@ import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import type { ClusterScopedMetadata, KubeObjectStatus } from "../kube-object"; import { KubeObject } from "../kube-object"; -import { metricsApi } from "./metrics.api"; -import type { PodMetricData } from "./pod.api"; export enum NamespaceStatusKind { ACTIVE = "Active", @@ -45,19 +43,3 @@ export class NamespaceApi extends KubeApi { }); } } - -export function getMetricsForNamespace(namespace: string, selector = ""): Promise { - const opts = { category: "pods", pods: ".*", namespace, selector }; - - return metricsApi.getMetrics({ - cpuUsage: opts, - memoryUsage: opts, - fsUsage: opts, - fsWrites: opts, - fsReads: opts, - networkReceive: opts, - networkTransmit: opts, - }, { - namespace, - }); -} diff --git a/src/common/k8s-api/endpoints/node.api.ts b/src/common/k8s-api/endpoints/node.api.ts index 15da73311b..e1d726bed1 100644 --- a/src/common/k8s-api/endpoints/node.api.ts +++ b/src/common/k8s-api/endpoints/node.api.ts @@ -6,8 +6,6 @@ import type { BaseKubeObjectCondition, ClusterScopedMetadata } from "../kube-object"; import { KubeObject } from "../kube-object"; import { cpuUnitsToNumber, unitsToBytes, isObject } from "../../../renderer/utils"; -import type { MetricData } from "./metrics.api"; -import { metricsApi } from "./metrics.api"; import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import { TypedRegEx } from "typed-regex"; @@ -21,32 +19,6 @@ export class NodeApi extends KubeApi { } } -export function getMetricsForAllNodes(): Promise { - const opts = { category: "nodes" }; - - return metricsApi.getMetrics({ - memoryUsage: opts, - workloadMemoryUsage: opts, - memoryCapacity: opts, - memoryAllocatableCapacity: opts, - cpuUsage: opts, - cpuCapacity: opts, - fsSize: opts, - fsUsage: opts, - }); -} - -export interface NodeMetricData extends Partial> { - memoryUsage: MetricData; - workloadMemoryUsage: MetricData; - memoryCapacity: MetricData; - memoryAllocatableCapacity: MetricData; - cpuUsage: MetricData; - cpuCapacity: MetricData; - fsUsage: MetricData; - fsSize: MetricData; -} - export interface NodeTaint { key: string; value?: string; diff --git a/src/common/k8s-api/endpoints/persistent-volume-claim.api.ts b/src/common/k8s-api/endpoints/persistent-volume-claim.api.ts index 733a9768c5..4c3c575699 100644 --- a/src/common/k8s-api/endpoints/persistent-volume-claim.api.ts +++ b/src/common/k8s-api/endpoints/persistent-volume-claim.api.ts @@ -5,8 +5,6 @@ import type { LabelSelector, NamespaceScopedMetadata, TypedLocalObjectReference } from "../kube-object"; import { KubeObject } from "../kube-object"; -import type { MetricData } from "./metrics.api"; -import { metricsApi } from "./metrics.api"; import type { Pod } from "./pod.api"; import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; @@ -22,22 +20,6 @@ export class PersistentVolumeClaimApi extends KubeApi { } } -export function getMetricsForPvc(pvc: PersistentVolumeClaim): Promise { - const opts = { category: "pvc", pvc: pvc.getName(), namespace: pvc.getNs() }; - - return metricsApi.getMetrics({ - diskUsage: opts, - diskCapacity: opts, - }, { - namespace: opts.namespace, - }); -} - -export interface PersistentVolumeClaimMetricData extends Partial> { - diskUsage: MetricData; - diskCapacity: MetricData; -} - export interface PersistentVolumeClaimSpec { accessModes?: string[]; dataSource?: TypedLocalObjectReference; diff --git a/src/common/k8s-api/endpoints/pod.api.ts b/src/common/k8s-api/endpoints/pod.api.ts index 5822c0c5ef..862a28da94 100644 --- a/src/common/k8s-api/endpoints/pod.api.ts +++ b/src/common/k8s-api/endpoints/pod.api.ts @@ -3,8 +3,6 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { MetricData } from "./metrics.api"; -import { metricsApi } from "./metrics.api"; import type { DerivedKubeApiOptions, IgnoredKubeApiOptions, ResourceDescriptor } from "../kube-api"; import { KubeApi } from "../kube-api"; import type { RequireExactlyOne } from "type-fest"; @@ -33,41 +31,6 @@ export class PodApi extends KubeApi { } } -export function getMetricsForPods(pods: Pod[], namespace: string, selector = "pod, namespace"): Promise { - const podSelector = pods.map(pod => pod.getName()).join("|"); - const opts = { category: "pods", pods: podSelector, namespace, selector }; - - return metricsApi.getMetrics({ - cpuUsage: opts, - cpuRequests: opts, - cpuLimits: opts, - memoryUsage: opts, - memoryRequests: opts, - memoryLimits: opts, - fsUsage: opts, - fsWrites: opts, - fsReads: opts, - networkReceive: opts, - networkTransmit: opts, - }, { - namespace, - }); -} - -export interface PodMetricData extends Partial> { - cpuUsage: MetricData; - memoryUsage: MetricData; - fsUsage: MetricData; - fsWrites: MetricData; - fsReads: MetricData; - networkReceive: MetricData; - networkTransmit: MetricData; - cpuRequests?: MetricData; - cpuLimits?: MetricData; - memoryRequests?: MetricData; - memoryLimits?: MetricData; -} - // Reference: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.19/#read-log-pod-v1-core export interface PodLogsQuery { container?: string; @@ -714,6 +677,10 @@ export class Pod extends KubeObject< return this.spec?.priorityClassName || ""; } + getRuntimeClassName() { + return this.spec?.runtimeClassName || ""; + } + getServiceAccountName() { return this.spec?.serviceAccountName || ""; } diff --git a/src/common/k8s-api/endpoints/replica-set.api.ts b/src/common/k8s-api/endpoints/replica-set.api.ts index ea3cdc25b9..401ba6cf05 100644 --- a/src/common/k8s-api/endpoints/replica-set.api.ts +++ b/src/common/k8s-api/endpoints/replica-set.api.ts @@ -5,8 +5,6 @@ import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import { metricsApi } from "./metrics.api"; -import type { PodMetricData } from "./pod.api"; import type { KubeObjectStatus, LabelSelector, NamespaceScopedMetadata } from "../kube-object"; import { KubeObject } from "../kube-object"; import type { PodTemplateSpec } from "./types/pod-template-spec"; @@ -41,23 +39,6 @@ export class ReplicaSetApi extends KubeApi { } } -export function getMetricsForReplicaSets(replicasets: ReplicaSet[], namespace: string, selector = ""): Promise { - const podSelector = replicasets.map(replicaset => `${replicaset.getName()}-[[:alnum:]]{5}`).join("|"); - const opts = { category: "pods", pods: podSelector, namespace, selector }; - - return metricsApi.getMetrics({ - cpuUsage: opts, - memoryUsage: opts, - fsUsage: opts, - fsWrites: opts, - fsReads: opts, - networkReceive: opts, - networkTransmit: opts, - }, { - namespace, - }); -} - export interface ReplicaSetSpec { replicas?: number; selector: LabelSelector; diff --git a/src/common/k8s-api/endpoints/resource-applier.api.ts b/src/common/k8s-api/endpoints/resource-applier.api.ts deleted file mode 100644 index 9f0c3bb6cd..0000000000 --- a/src/common/k8s-api/endpoints/resource-applier.api.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import yaml from "js-yaml"; -import type { KubeJsonApiData } from "../kube-json-api"; -import { apiBase } from "../index"; -import type { Patch } from "rfc6902"; - -export const annotations = [ - "kubectl.kubernetes.io/last-applied-configuration", -]; - -export async function update(resource: object | string): Promise { - if (typeof resource === "string") { - const parsed = yaml.load(resource); - - if (!parsed || typeof parsed !== "object") { - throw new Error("Cannot update resource to string or number"); - } - - resource = parsed; - } - - return apiBase.post("/stack", { data: resource }); -} - -export async function patch(name: string, kind: string, ns: string | undefined, patch: Patch): Promise { - return apiBase.patch("/stack", { - data: { - name, - kind, - ns, - patch, - }, - }); -} diff --git a/src/common/k8s-api/endpoints/resource-applier.api/request-patch.injectable.ts b/src/common/k8s-api/endpoints/resource-applier.api/request-patch.injectable.ts new file mode 100644 index 0000000000..c8ad3435fd --- /dev/null +++ b/src/common/k8s-api/endpoints/resource-applier.api/request-patch.injectable.ts @@ -0,0 +1,30 @@ +/** + * 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 type { Patch } from "rfc6902"; +import { apiBaseInjectionToken } from "../../api-base"; +import type { KubeJsonApiData } from "../../kube-json-api"; + +export type RequestKubeObjectPatch = (name: string, kind: string, ns: string | undefined, patch: Patch) => Promise; + +const requestKubeObjectPatchInjectable = getInjectable({ + id: "request-kube-object-patch", + instantiate: (di): RequestKubeObjectPatch => { + const apiBase = di.inject(apiBaseInjectionToken); + + return (name, kind, ns, patch) => ( + apiBase.patch("/stack", { + data: { + name, + kind, + ns, + patch, + }, + }) + ); + }, +}); + +export default requestKubeObjectPatchInjectable; diff --git a/src/common/k8s-api/endpoints/resource-applier.api/request-update.injectable.ts b/src/common/k8s-api/endpoints/resource-applier.api/request-update.injectable.ts new file mode 100644 index 0000000000..52824cd86c --- /dev/null +++ b/src/common/k8s-api/endpoints/resource-applier.api/request-update.injectable.ts @@ -0,0 +1,20 @@ +/** + * 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 { apiBaseInjectionToken } from "../../api-base"; +import type { KubeJsonApiData } from "../../kube-json-api"; + +export type RequestKubeObjectCreation = (resourceDescriptor: string) => Promise; + +const requestKubeObjectCreationInjectable = getInjectable({ + id: "request-kube-object-creation", + instantiate: (di): RequestKubeObjectCreation => { + const apiBase = di.inject(apiBaseInjectionToken); + + return (data) => apiBase.post("/stack", { data }); + }, +}); + +export default requestKubeObjectCreationInjectable; diff --git a/src/common/k8s-api/endpoints/runtime-class.api.injectable.ts b/src/common/k8s-api/endpoints/runtime-class.api.injectable.ts new file mode 100644 index 0000000000..1b13d13270 --- /dev/null +++ b/src/common/k8s-api/endpoints/runtime-class.api.injectable.ts @@ -0,0 +1,22 @@ +/** + * 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 assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { RuntimeClassApi } from "./runtime-class.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; + +const runtimeClassApiInjectable = getInjectable({ + id: "runtime-class-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "RuntimeClassApi is only available in certain environments"); + + return new RuntimeClassApi(); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default runtimeClassApiInjectable; diff --git a/src/common/k8s-api/endpoints/runtime-class.api.ts b/src/common/k8s-api/endpoints/runtime-class.api.ts new file mode 100644 index 0000000000..fa1867ea07 --- /dev/null +++ b/src/common/k8s-api/endpoints/runtime-class.api.ts @@ -0,0 +1,72 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { DerivedKubeApiOptions } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { KubeJsonApiData } from "../kube-json-api"; +import type { ClusterScopedMetadata, KubeObjectMetadata, KubeObjectScope, Toleration } from "../kube-object"; +import { KubeObject } from "../kube-object"; + +export interface RuntimeClassData extends KubeJsonApiData, void, void> { + handler: string; + overhead?: RuntimeClassOverhead; + scheduling?: RuntimeClassScheduling; +} + +export interface RuntimeClassOverhead { + podFixed?: string; +} + +export interface RuntimeClassScheduling { + nodeSelector?: Partial>; + tolerations?: Toleration[]; +} + +export class RuntimeClass extends KubeObject< + ClusterScopedMetadata, + void, + void +> { + static readonly kind = "RuntimeClass"; + static readonly namespaced = false; + static readonly apiBase = "/apis/node.k8s.io/v1/runtimeclasses"; + + handler: string; + overhead?: RuntimeClassOverhead; + scheduling?: RuntimeClassScheduling; + + constructor({ handler, overhead, scheduling, ...rest }: RuntimeClassData) { + super(rest); + this.handler = handler; + this.overhead = overhead; + this.scheduling = scheduling; + } + + getHandler() { + return this.handler; + } + + getPodFixed() { + return this.overhead?.podFixed ?? ""; + } + + getNodeSelectors(): string[] { + return Object.entries(this.scheduling?.nodeSelector ?? {}) + .map(values => values.join(": ")); + } + + getTolerations() { + return this.scheduling?.tolerations ?? []; + } +} + +export class RuntimeClassApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + objectConstructor: RuntimeClass, + ...opts, + }); + } +} diff --git a/src/common/k8s-api/endpoints/stateful-set.api.ts b/src/common/k8s-api/endpoints/stateful-set.api.ts index f6e36c36fb..1d6895f934 100644 --- a/src/common/k8s-api/endpoints/stateful-set.api.ts +++ b/src/common/k8s-api/endpoints/stateful-set.api.ts @@ -5,8 +5,6 @@ import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import { metricsApi } from "./metrics.api"; -import type { PodMetricData } from "./pod.api"; import type { LabelSelector, NamespaceScopedMetadata } from "../kube-object"; import { KubeObject } from "../kube-object"; import type { PodTemplateSpec } from "./types/pod-template-spec"; @@ -46,23 +44,6 @@ export class StatefulSetApi extends KubeApi { } } -export function getMetricsForStatefulSets(statefulSets: StatefulSet[], namespace: string, selector = ""): Promise { - const podSelector = statefulSets.map(statefulset => `${statefulset.getName()}-[[:digit:]]+`).join("|"); - const opts = { category: "pods", pods: podSelector, namespace, selector }; - - return metricsApi.getMetrics({ - cpuUsage: opts, - memoryUsage: opts, - fsUsage: opts, - fsWrites: opts, - fsReads: opts, - networkReceive: opts, - networkTransmit: opts, - }, { - namespace, - }); -} - export interface StatefulSetSpec { serviceName: string; replicas: number; diff --git a/src/common/k8s-api/json-api.ts b/src/common/k8s-api/json-api.ts index 36b4fdd9f5..c3e07bfa94 100644 --- a/src/common/k8s-api/json-api.ts +++ b/src/common/k8s-api/json-api.ts @@ -9,12 +9,12 @@ import { Agent as HttpAgent } from "http"; import { Agent as HttpsAgent } from "https"; import { merge } from "lodash"; import type { Response, RequestInit } from "node-fetch"; -import fetch from "node-fetch"; import { stringify } from "querystring"; import type { Patch } from "rfc6902"; import type { PartialDeep, ValueOf } from "type-fest"; import { EventEmitter } from "../../common/event-emitter"; -import logger from "../../common/logger"; +import type { Logger } from "../../common/logger"; +import type { Fetch } from "../fetch/fetch.injectable"; import type { Defaulted } from "../utils"; import { json } from "../utils"; @@ -59,6 +59,11 @@ export type ParamsAndQuery = ( : Params & { query?: undefined } ); +export interface JsonApiDependencies { + fetch: Fetch; + readonly logger: Logger; +} + export class JsonApi = JsonApiParams> { static readonly reqInitDefault = { headers: { @@ -71,7 +76,7 @@ export class JsonApi = Js debug: false, }; - constructor(public readonly config: JsonApiConfig, reqInit?: RequestInit) { + constructor(protected readonly dependencies: JsonApiDependencies, public readonly config: JsonApiConfig, reqInit?: RequestInit) { this.config = Object.assign({}, JsonApi.configDefault, config); this.reqInit = merge({}, JsonApi.reqInitDefault, reqInit); this.parseResponse = this.parseResponse.bind(this); @@ -105,7 +110,7 @@ export class JsonApi = Js reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString; } - return fetch(reqUrl, reqInit); + return this.dependencies.fetch(reqUrl, reqInit); } get( @@ -177,7 +182,7 @@ export class JsonApi = Js reqInit, }; - const res = await fetch(reqUrl, reqInit); + const res = await this.dependencies.fetch(reqUrl, reqInit); return this.parseResponse(res, infoLog); } @@ -233,7 +238,7 @@ export class JsonApi = Js protected writeLog(log: JsonApiLog) { const { method, reqUrl, ...params } = log; - logger.debug(`[JSON-API] request ${method} ${reqUrl}`, params); + this.dependencies.logger.debug(`[JSON-API] request ${method} ${reqUrl}`, params); } } diff --git a/src/common/k8s-api/kube-api-parse.ts b/src/common/k8s-api/kube-api-parse.ts index 509f1c28e9..dcb8b18636 100644 --- a/src/common/k8s-api/kube-api-parse.ts +++ b/src/common/k8s-api/kube-api-parse.ts @@ -17,6 +17,7 @@ export interface IKubeApiLinkRef { export interface IKubeApiParsed extends IKubeApiLinkRef { apiBase: string; + apiPrefix: string; apiGroup: string; apiVersionWithGroup: string; } diff --git a/src/common/k8s-api/kube-api.ts b/src/common/k8s-api/kube-api.ts index ddc2456668..7ca2995906 100644 --- a/src/common/k8s-api/kube-api.ts +++ b/src/common/k8s-api/kube-api.ts @@ -5,29 +5,28 @@ // Base class for building all kubernetes apis -import { isFunction, merge } from "lodash"; +import { merge } from "lodash"; import { stringify } from "querystring"; -import { apiKubePrefix, isDevelopment } from "../../common/vars"; -import { apiBase, apiKube } from "./index"; import { createKubeApiURL, parseKubeApi } from "./kube-api-parse"; import type { KubeObjectConstructor, KubeJsonApiDataFor, KubeObjectMetadata } from "./kube-object"; import { KubeObject, KubeStatus, isKubeStatusData } from "./kube-object"; import byline from "byline"; import type { IKubeWatchEvent } from "./kube-watch-event"; -import type { KubeJsonApiData } from "./kube-json-api"; -import { KubeJsonApi } from "./kube-json-api"; +import type { KubeJsonApiData, KubeJsonApi } from "./kube-json-api"; import type { Disposer } from "../utils"; import { isDefined, noop, WrappedAbortController } from "../utils"; -import type { RequestInit } from "node-fetch"; -import type { AgentOptions } from "https"; -import { Agent } from "https"; +import type { RequestInit, Response } from "node-fetch"; import type { Patch } from "rfc6902"; import assert from "assert"; import type { PartialDeep } from "type-fest"; -import logger from "../logger"; +import type { Logger } from "../logger"; import { Environments, getEnvironmentSpecificLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; import autoRegistrationEmitterInjectable from "./api-manager/auto-registration-emitter.injectable"; +import { asLegacyGlobalForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import { apiKubeInjectionToken } from "./api-kube"; import type AbortController from "abort-controller"; +import loggerInjectable from "../logger.injectable"; +import { matches } from "lodash/fp"; /** * The options used for creating a `KubeApi` @@ -145,146 +144,59 @@ export interface KubeApiResourceList { resources: KubeApiResource[]; } -export interface ILocalKubeApiConfig { - metadata: { - uid: string; - }; +export interface KubeApiResourceVersion { + groupVersion: string; + version: string; } +export interface KubeApiResourceVersionList { + apiVersion: string; + kind: string; + name: string; + preferredVersion: KubeApiResourceVersion; + versions: KubeApiResourceVersion[]; +} + +const not = (fn: (val: T) => boolean) => (val: T) => !(fn(val)); + +const getOrderedVersions = (list: KubeApiResourceVersionList): KubeApiResourceVersion[] => [ + list.preferredVersion, + ...list.versions.filter(not(matches(list.preferredVersion))), +]; + export type PropagationPolicy = undefined | "Orphan" | "Foreground" | "Background"; -/** - * @deprecated - */ -export interface IKubeApiCluster extends ILocalKubeApiConfig { } - -export interface IRemoteKubeApiConfig { - cluster: { - server: string; - caData?: string; - skipTLSVerify?: boolean; - }; - user: { - token?: string | (() => Promise); - clientCertificateData?: string; - clientKeyData?: string; - }; - /** - * Custom instance of https.agent to use for the requests - * - * @remarks the custom agent replaced default agent, options skipTLSVerify, - * clientCertificateData, clientKeyData and caData are ignored. - */ - agent?: Agent; -} - -export function forCluster< - Object extends KubeObject, - Api extends KubeApi, - Data extends KubeJsonApiDataFor, ->(cluster: ILocalKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: new (apiOpts: KubeApiOptions) => Api): Api; -export function forCluster< - Object extends KubeObject, - Data extends KubeJsonApiDataFor, ->(cluster: ILocalKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass?: new (apiOpts: KubeApiOptions) => KubeApi): KubeApi; - -export function forCluster< - Object extends KubeObject, - Data extends KubeJsonApiDataFor, ->(cluster: ILocalKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: (new (apiOpts: KubeApiOptions) => KubeApi) = KubeApi): KubeApi { - const url = new URL(apiBase.config.serverAddress); - const request = new KubeJsonApi({ - serverAddress: apiBase.config.serverAddress, - apiBase: apiKubePrefix, - debug: isDevelopment, - }, { - headers: { - "Host": `${cluster.metadata.uid}.localhost:${url.port}`, - }, - }); - - return new apiClass({ - objectConstructor: kubeClass as KubeObjectConstructor>, - request, - }); -} - -export function forRemoteCluster< - Object extends KubeObject, - Api extends KubeApi, - Data extends KubeJsonApiDataFor, ->(config: IRemoteKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: new (apiOpts: KubeApiOptions) => Api): Api; -export function forRemoteCluster< - Object extends KubeObject, - Data extends KubeJsonApiDataFor, ->(config: IRemoteKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass?: new (apiOpts: KubeApiOptions) => KubeApi): KubeApi; - -export function forRemoteCluster< - Object extends KubeObject, - Api extends KubeApi, - Data extends KubeJsonApiDataFor, ->(config: IRemoteKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: new (apiOpts: KubeApiOptions) => KubeApi = KubeApi): KubeApi { - const reqInit: RequestInit = {}; - const agentOptions: AgentOptions = {}; - - if (config.cluster.skipTLSVerify === true) { - agentOptions.rejectUnauthorized = false; - } - - if (config.user.clientCertificateData) { - agentOptions.cert = config.user.clientCertificateData; - } - - if (config.user.clientKeyData) { - agentOptions.key = config.user.clientKeyData; - } - - if (config.cluster.caData) { - agentOptions.ca = config.cluster.caData; - } - - if (Object.keys(agentOptions).length > 0) { - reqInit.agent = new Agent(agentOptions); - } - - if (config.agent) { - reqInit.agent = config.agent; - } - - const token = config.user.token; - const request = new KubeJsonApi({ - serverAddress: config.cluster.server, - apiBase: "", - debug: isDevelopment, - ...(token ? { - getRequestOptions: async () => ({ - headers: { - "Authorization": `Bearer ${isFunction(token) ? await token() : token}`, - }, - }), - } : {}), - }, reqInit); - - if (!apiClass) { - apiClass = KubeApi as new (apiOpts: KubeApiOptions) => Api; - } - - return new apiClass({ - objectConstructor: kubeClass as KubeObjectConstructor>, - request, - }); -} - -export type KubeApiWatchCallback = (data: IKubeWatchEvent, error: any) => void; +export type KubeApiWatchCallback = (data: IKubeWatchEvent | null, error: KubeStatus | Response | null | any) => void; export interface KubeApiWatchOptions> { - namespace: string; + /** + * If the resource is namespaced then the default is `"default"` + */ + namespace?: string; + + /** + * This will be called when either an error occurs or some data is received + */ callback?: KubeApiWatchCallback; + + /** + * This is a way of aborting the request + */ abortController?: AbortController; + + /** + * The ID used for tracking within logs + */ watchId?: string; + + /** + * @default false + */ retry?: boolean; - // timeout in seconds + /** + * timeout in seconds + */ timeout?: number; } @@ -343,6 +255,10 @@ function legacyRegisterApi(api: KubeApi): void { } } +export interface KubeApiDependencies { + readonly logger: Logger; +} + export class KubeApi< Object extends KubeObject = KubeObject, Data extends KubeJsonApiDataFor = KubeJsonApiDataFor, @@ -365,10 +281,12 @@ export class KubeApi< protected readonly fullApiPathname: string; protected readonly fallbackApiBases: string[] | undefined; + protected readonly dependencies: KubeApiDependencies; + constructor(opts: KubeApiOptions) { const { objectConstructor, - request = apiKube, + request = asLegacyGlobalForExtensionApi(apiKubeInjectionToken), kind = objectConstructor.kind, isNamespaced, apiBase: fullApiPathname = objectConstructor.apiBase, @@ -397,6 +315,10 @@ export class KubeApi< this.request = request; this.objectConstructor = objectConstructor; legacyRegisterApi(this); + + this.dependencies = { + logger: asLegacyGlobalForExtensionApi(loggerInjectable), + }; } get apiVersionWithGroup() { @@ -411,23 +333,29 @@ export class KubeApi< */ private async getLatestApiPrefixGroup() { // Note that this.fullApiPathname is the "full" url, whereas this.apiBase is parsed - const apiBases = [this.fullApiPathname, this.objectConstructor.apiBase, ...this.fallbackApiBases ?? []]; + const rawApiBases = [ + this.fullApiPathname, + this.objectConstructor.apiBase, + ...this.fallbackApiBases ?? [], + ].filter(isDefined); + const apiBases = new Set(rawApiBases); for (const apiUrl of apiBases) { - if (!apiUrl) { - continue; - } - try { - // Split e.g. "/apis/extensions/v1beta1/ingresses" to parts - const { apiPrefix, apiGroup, apiVersionWithGroup, resource } = parseKubeApi(apiUrl); + const { apiPrefix, apiGroup, resource } = parseKubeApi(apiUrl); + const list = await this.request.get(`${apiPrefix}/${apiGroup}`) as KubeApiResourceVersionList; + const resourceVersions = getOrderedVersions(list); - // Request available resources - const { resources } = (await this.request.get(`${apiPrefix}/${apiVersionWithGroup}`)) as unknown as KubeApiResourceList; + for (const resourceVersion of resourceVersions) { + const { resources } = await this.request.get(`${apiPrefix}/${resourceVersion.groupVersion}`) as KubeApiResourceList; - // If the resource is found in the group, use this apiUrl - if (resources.find(({ name }) => name === resource)) { - return { apiPrefix, apiGroup }; + if (resources.some(({ name }) => name === resource)) { + return { + apiPrefix, + apiGroup, + apiVersionPreferred: resourceVersion.version, + }; + } } } catch (error) { // Exception is ignored as we can try the next url @@ -437,48 +365,19 @@ export class KubeApi< throw new Error(`Can't find working API for the Kubernetes resource ${this.apiResource}`); } - /** - * Get the apiPrefix and apiGroup to be used for fetching the preferred version. - */ - private async getPreferredVersionPrefixGroup() { - if (this.fallbackApiBases) { - try { - return await this.getLatestApiPrefixGroup(); - } catch (error) { - // If valid API wasn't found, log the error and return defaults below - logger.error(`[KUBE-API]: ${error}`); - } - } - - return { - apiPrefix: this.apiPrefix, - apiGroup: this.apiGroup, - }; - } - protected async checkPreferredVersion() { if (this.fallbackApiBases && !this.doCheckPreferredVersion) { throw new Error("checkPreferredVersion must be enabled if fallbackApiBases is set in KubeApi"); } if (this.doCheckPreferredVersion && this.apiVersionPreferred === undefined) { - const { apiPrefix, apiGroup } = await this.getPreferredVersionPrefixGroup(); + const { apiPrefix, apiGroup, apiVersionPreferred } = await this.getLatestApiPrefixGroup(); - assert(apiPrefix); - - // The apiPrefix and apiGroup might change due to fallbackApiBases, so we must override them this.apiPrefix = apiPrefix; this.apiGroup = apiGroup; - - const url = [apiPrefix, apiGroup].filter(Boolean).join("/"); - const res = await this.request.get(url) as IKubePreferredVersion; - - this.apiVersionPreferred = res?.preferredVersion?.version; - - if (this.apiVersionPreferred) { - this.apiBase = this.computeApiBase(); - legacyRegisterApi(this); - } + this.apiVersionPreferred = apiVersionPreferred; + this.apiBase = this.computeApiBase(); + legacyRegisterApi(this); } } @@ -502,18 +401,47 @@ export class KubeApi< }); } - getUrl({ name, namespace }: Partial = {}, query?: Partial) { + /** + * This method differs from {@link formatUrlForNotListing} because this treats `""` as "all namespaces" + * @param namespace The namespace to list in or `""` for all namespaces + */ + formatUrlForListing(namespace: string) { + return createKubeApiURL({ + apiPrefix: this.apiPrefix, + apiVersion: this.apiVersionWithGroup, + resource: this.apiResource, + namespace: this.isNamespaced + ? namespace ?? "default" + : undefined, + }); + } + + /** + * Format a URL pathname and query for acting upon a specific resource. + */ + formatUrlForNotListing(resource?: Partial, query?: Partial): string; + + formatUrlForNotListing({ name, namespace }: Partial = {}, query?: Partial) { const resourcePath = createKubeApiURL({ apiPrefix: this.apiPrefix, apiVersion: this.apiVersionWithGroup, resource: this.apiResource, - namespace: this.isNamespaced ? namespace ?? "default" : undefined, + namespace: this.isNamespaced + ? namespace || "default" + : undefined, name, }); return resourcePath + (query ? `?${stringify(this.normalizeQuery(query))}` : ""); } + /** + * @deprecated use {@link formatUrlForNotListing} instead + */ + getUrl(resource?: Partial, query?: Partial) { + return this.formatUrlForNotListing(resource, query); + } + protected normalizeQuery(query: Partial = {}) { if (query.labelSelector) { query.labelSelector = [query.labelSelector].flat().join(","); @@ -591,7 +519,7 @@ export class KubeApi< async list({ namespace = "", reqInit }: KubeApiListOptions = {}, query?: KubeApiQueryParams): Promise { await this.checkPreferredVersion(); - const url = this.getUrl({ namespace }); + const url = this.formatUrlForListing(namespace); const res = await this.request.get(url, { query }, reqInit); const parsed = this.parseResponse(res, namespace); @@ -609,7 +537,7 @@ export class KubeApi< async get(desc: ResourceDescriptor, query?: KubeApiQueryParams): Promise { await this.checkPreferredVersion(); - const url = this.getUrl(desc); + const url = this.formatUrlForNotListing(desc); const res = await this.request.get(url, { query }); const parsed = this.parseResponse(res); @@ -623,7 +551,7 @@ export class KubeApi< async create({ name, namespace }: Partial, partialData?: PartialDeep): Promise { await this.checkPreferredVersion(); - const apiUrl = this.getUrl({ namespace }); + const apiUrl = this.formatUrlForNotListing({ namespace }); const data = merge(partialData, { kind: this.kind, apiVersion: this.apiVersionWithGroup, @@ -644,7 +572,7 @@ export class KubeApi< async update({ name, namespace }: ResourceDescriptor, data: PartialDeep): Promise { await this.checkPreferredVersion(); - const apiUrl = this.getUrl({ namespace, name }); + const apiUrl = this.formatUrlForNotListing({ namespace, name }); const res = await this.request.put(apiUrl, { data: merge(data, { @@ -663,9 +591,13 @@ export class KubeApi< return parsed; } + async patch(desc: ResourceDescriptor, data: PartialDeep): Promise; + async patch(desc: ResourceDescriptor, data: PartialDeep, strategy: "strategic" | "merge"): Promise; + async patch(desc: ResourceDescriptor, data: Patch, strategy: "json"): Promise; + async patch(desc: ResourceDescriptor, data: PartialDeep | Patch, strategy: KubeApiPatchType): Promise; async patch(desc: ResourceDescriptor, data: PartialDeep | Patch, strategy: KubeApiPatchType = "strategic"): Promise { await this.checkPreferredVersion(); - const apiUrl = this.getUrl(desc); + const apiUrl = this.formatUrlForNotListing(desc); const res = await this.request.patch(apiUrl, { data }, { headers: { @@ -683,7 +615,7 @@ export class KubeApi< async delete({ propagationPolicy = "Background", ...desc }: DeleteResourceDescriptor) { await this.checkPreferredVersion(); - const apiUrl = this.getUrl(desc); + const apiUrl = this.formatUrlForNotListing(desc); return this.request.del(apiUrl, { query: { @@ -692,25 +624,30 @@ export class KubeApi< }); } - getWatchUrl(namespace = "", query: KubeApiQueryParams = {}) { - return this.getUrl({ namespace }, { + getWatchUrl(namespace?: string, query: KubeApiQueryParams = {}) { + return this.formatUrlForNotListing({ namespace }, { watch: 1, resourceVersion: this.getResourceVersion(namespace), ...query, }); } - watch(opts: KubeApiWatchOptions = { namespace: "", retry: false }): () => void { + watch(opts?: KubeApiWatchOptions): () => void { let errorReceived = false; let timedRetry: NodeJS.Timeout; - const { namespace, callback = noop, retry, timeout } = opts; - const { watchId = `${this.kind.toLowerCase()}-${this.watchId++}` } = opts; + const { + namespace, + callback = noop as KubeApiWatchCallback, + retry = false, + timeout = 600, + watchId = `${this.kind.toLowerCase()}-${this.watchId++}`, + } = opts ?? {}; // Create AbortController for this request - const abortController = new WrappedAbortController(opts.abortController); + const abortController = new WrappedAbortController(opts?.abortController); abortController.signal.addEventListener("abort", () => { - logger.info(`[KUBE-API] watch (${watchId}) aborted ${watchUrl}`); + this.dependencies.logger.info(`[KUBE-API] watch (${watchId}) aborted ${watchUrl}`); clearTimeout(timedRetry); }); @@ -718,10 +655,9 @@ export class KubeApi< const watchUrl = this.getWatchUrl(namespace); const responsePromise = this.request.getResponse(watchUrl, requestParams, { signal: abortController.signal, - timeout: 600_000, }); - logger.info(`[KUBE-API] watch (${watchId}) ${retry === true ? "retried" : "started"} ${watchUrl}`); + this.dependencies.logger.info(`[KUBE-API] watch (${watchId}) ${retry === true ? "retried" : "started"} ${watchUrl}`); responsePromise .then(response => { @@ -729,7 +665,7 @@ export class KubeApi< let requestRetried = false; if (!response.ok) { - logger.warn(`[KUBE-API] watch (${watchId}) error response ${watchUrl}`, { status: response.status }); + this.dependencies.logger.warn(`[KUBE-API] watch (${watchId}) error response ${watchUrl}`, { status: response.status }); return callback(null, response); } @@ -746,7 +682,7 @@ export class KubeApi< // Close current request abortController.abort(); - logger.info(`[KUBE-API] Watch timeout set, but not retried, retrying now`); + this.dependencies.logger.info(`[KUBE-API] Watch timeout set, but not retried, retrying now`); requestRetried = true; @@ -757,7 +693,21 @@ export class KubeApi< }, timeout * 1000 * 1.1); } - ["end", "close", "error"].forEach((eventName) => { + if (!response.body || !response.body.readable) { + if (!response.body) { + this.dependencies.logger.warn(`[KUBE-API]: watch (${watchId}) did not return a body`); + } + requestRetried = true; + + clearTimeout(timedRetry); + timedRetry = setTimeout(() => { // we did not get any kubernetes errors so let's retry + this.watch({ ...opts, namespace, callback, watchId, retry: true }); + }, 1000); + + return; + } + + for (const eventName of ["end", "close", "error"]) { response.body.on(eventName, () => { // We only retry if we haven't retried, haven't aborted and haven't received k8s error // kubernetes errors (=errorReceived set) should be handled in a callback @@ -765,7 +715,7 @@ export class KubeApi< return; } - logger.info(`[KUBE-API] watch (${watchId}) ${eventName} ${watchUrl}`); + this.dependencies.logger.info(`[KUBE-API] watch (${watchId}) ${eventName} ${watchUrl}`); requestRetried = true; @@ -774,11 +724,11 @@ export class KubeApi< this.watch({ ...opts, namespace, callback, watchId, retry: true }); }, 1000); }); - }); + } byline(response.body).on("data", (line) => { try { - const event = JSON.parse(line) as IKubeWatchEvent>; + const event = JSON.parse(line) as IKubeWatchEvent; if (event.type === "ERROR" && isKubeStatusData(event.object)) { errorReceived = true; @@ -794,8 +744,9 @@ export class KubeApi< }); }) .catch(error => { - logger.error(`[KUBE-API] watch (${watchId}) throwed ${watchUrl}`, error); - + if (!abortController.signal.aborted) { + this.dependencies.logger.error(`[KUBE-API] watch (${watchId}) threw ${watchUrl}`, error); + } callback(null, error); }); diff --git a/src/common/k8s-api/kube-json-api.ts b/src/common/k8s-api/kube-json-api.ts index 2624f030c8..16ca5cda70 100644 --- a/src/common/k8s-api/kube-json-api.ts +++ b/src/common/k8s-api/kube-json-api.ts @@ -6,8 +6,6 @@ import type { JsonApiData, JsonApiError } from "./json-api"; import { JsonApi } from "./json-api"; import type { Response } from "node-fetch"; -import { apiKubePrefix, isDebugging } from "../vars"; -import { apiBase } from "./api-base"; import type { KubeJsonApiObjectMetadata } from "./kube-object"; export interface KubeJsonApiListMetadata { @@ -47,20 +45,6 @@ export interface KubeJsonApiError extends JsonApiError { } export class KubeJsonApi extends JsonApi { - static forCluster(clusterId: string): KubeJsonApi { - const url = new URL(apiBase.config.serverAddress); - - return new this({ - serverAddress: apiBase.config.serverAddress, - apiBase: apiKubePrefix, - debug: isDebugging, - }, { - headers: { - "Host": `${clusterId}.localhost:${url.port}`, - }, - }); - } - protected parseError(error: KubeJsonApiError | string, res: Response): string[] { if (typeof error === "string") { return [error]; diff --git a/src/common/k8s-api/kube-object.store.ts b/src/common/k8s-api/kube-object.store.ts index 5412bbc87c..309c183e42 100644 --- a/src/common/k8s-api/kube-object.store.ts +++ b/src/common/k8s-api/kube-object.store.ts @@ -7,7 +7,7 @@ import type { ClusterContext } from "./cluster-context"; import { action, computed, makeObservable, observable, reaction, when } from "mobx"; import type { Disposer } from "../utils"; -import { waitUntilDefined, autoBind, includes, isRequestError, noop, rejectPromiseBy } from "../utils"; +import { waitUntilDefined, autoBind, includes, noop, rejectPromiseBy } from "../utils"; import type { KubeJsonApiDataFor, KubeObject } from "./kube-object"; import { KubeStatus } from "./kube-object"; import type { IKubeWatchEvent } from "./kube-watch-event"; @@ -221,11 +221,7 @@ export abstract class KubeObjectStore< try { return await res ?? []; } catch (error) { - onLoadFailure(( - isRequestError(error) - ? error.message || error.toString() - : "Unknown error" - )); + onLoadFailure(new Error(`Failed to load ${this.api.apiBase}`, { cause: error })); // reset the store because we are loading all, so that nothing is displayed this.items.clear(); @@ -253,7 +249,7 @@ export abstract class KubeObjectStore< case "rejected": if (onLoadFailure) { - onLoadFailure(result.reason.message || result.reason); + onLoadFailure(new Error(`Failed to load ${this.api.apiBase}`, { cause: result.reason })); } else { // if onLoadFailure is not provided then preserve old behaviour throw result.reason; @@ -432,7 +428,7 @@ export abstract class KubeObjectStore< protected eventsBuffer = observable.array>([], { deep: false }); protected bindWatchEventsUpdater(delay = 1000) { - reaction(() => this.eventsBuffer.length, this.updateFromEventsBuffer, { + reaction(() => [...this.eventsBuffer], this.updateFromEventsBuffer, { delay, }); } @@ -477,7 +473,6 @@ export abstract class KubeObjectStore< callback, }); - // TODO: upgrade node-fetch once we are starting to use ES modules const signal = abortController.signal; const callback: KubeApiWatchCallback = (data, error) => { diff --git a/src/common/k8s-api/kube-object.ts b/src/common/k8s-api/kube-object.ts index 0851729bc7..24e34eff4b 100644 --- a/src/common/k8s-api/kube-object.ts +++ b/src/common/k8s-api/kube-object.ts @@ -9,11 +9,15 @@ import moment from "moment"; import type { KubeJsonApiData, KubeJsonApiDataList, KubeJsonApiListMetadata } from "./kube-json-api"; import { autoBind, formatDuration, hasOptionalTypedProperty, hasTypedProperty, isObject, isString, isNumber, bindPredicate, isTypedArray, isRecord, json } from "../utils"; import type { ItemObject } from "../item.store"; -import { apiKube } from "./index"; -import * as resourceApplierApi from "./endpoints/resource-applier.api"; import type { Patch } from "rfc6902"; import assert from "assert"; import type { JsonObject } from "type-fest"; +import { asLegacyGlobalFunctionForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api"; +import requestKubeObjectPatchInjectable from "./endpoints/resource-applier.api/request-patch.injectable"; +import { asLegacyGlobalForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import { apiKubeInjectionToken } from "./api-kube"; +import requestKubeObjectCreationInjectable from "./endpoints/resource-applier.api/request-update.injectable"; +import { dump } from "js-yaml"; export type KubeJsonApiDataFor = K extends KubeObject ? KubeJsonApiData @@ -375,6 +379,12 @@ export type ScopedNamespace = ( : string | undefined ); +const resourceApplierAnnotationsForFiltering = [ + "kubectl.kubernetes.io/last-applied-configuration", +]; + +const filterOutResourceApplierAnnotations = (label: string) => !resourceApplierAnnotationsForFiltering.some(key => label.startsWith(key)); + export class KubeObject< Metadata extends KubeObjectMetadata = KubeObjectMetadata, Status = unknown, @@ -588,11 +598,11 @@ export class KubeObject< getAnnotations(filter = false): string[] { const labels = KubeObject.stringifyLabels(this.metadata.annotations); - return filter ? labels.filter(label => { - const skip = resourceApplierApi.annotations.some(key => label.startsWith(key)); + if (!filter) { + return labels; + } - return !skip; - }) : labels; + return labels.filter(filterOutResourceApplierAnnotations); } getOwnerRefs() { @@ -634,7 +644,9 @@ export class KubeObject< } } - return resourceApplierApi.patch(this.getName(), this.kind, this.getNs(), patch); + const requestKubeObjectPatch = asLegacyGlobalFunctionForExtensionApi(requestKubeObjectPatchInjectable); + + return requestKubeObjectPatch(this.getName(), this.kind, this.getNs(), patch); } /** @@ -647,11 +659,13 @@ export class KubeObject< * @deprecated use KubeApi.update instead */ async update(data: Partial): Promise { - // use unified resource-applier api for updating all k8s objects - return resourceApplierApi.update({ + const requestKubeObjectCreation = asLegacyGlobalFunctionForExtensionApi(requestKubeObjectCreationInjectable); + const descriptor = dump({ ...this.toPlainObject(), ...data, }); + + return requestKubeObjectCreation(descriptor); } /** @@ -660,6 +674,8 @@ export class KubeObject< delete(params?: object) { assert(this.selfLink, "selfLink must be present to delete self"); + const apiKube = asLegacyGlobalForExtensionApi(apiKubeInjectionToken); + return apiKube.del(this.selfLink, params); } } diff --git a/src/common/k8s-api/window-location.global-override-for-injectable.ts b/src/common/k8s-api/window-location.global-override-for-injectable.ts new file mode 100644 index 0000000000..616e110c88 --- /dev/null +++ b/src/common/k8s-api/window-location.global-override-for-injectable.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import windowLocationInjectable from "./window-location.injectable"; + +export default getGlobalOverride(windowLocationInjectable, () => ({ + host: "localhost", + port: "12345", +})); diff --git a/src/common/k8s-api/window-location.injectable.ts b/src/common/k8s-api/window-location.injectable.ts new file mode 100644 index 0000000000..80bcd44be6 --- /dev/null +++ b/src/common/k8s-api/window-location.injectable.ts @@ -0,0 +1,17 @@ +/** + * 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"; + +const windowLocationInjectable = getInjectable({ + id: "window-location", + instantiate: () => { + const { host, port } = window.location; + + return { host, port }; + }, + causesSideEffects: true, +}); + +export default windowLocationInjectable; diff --git a/src/common/k8s/create-resource-stack.injectable.ts b/src/common/k8s/create-resource-stack.injectable.ts new file mode 100644 index 0000000000..083d240b3a --- /dev/null +++ b/src/common/k8s/create-resource-stack.injectable.ts @@ -0,0 +1,33 @@ +/** + * 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 type { KubernetesCluster } from "../catalog-entities"; +import readDirectoryInjectable from "../fs/read-directory.injectable"; +import readFileInjectable from "../fs/read-file.injectable"; +import { kubectlApplyAllInjectionToken, kubectlDeleteAllInjectionToken } from "../kube-helpers/channels"; +import loggerInjectable from "../logger.injectable"; +import joinPathsInjectable from "../path/join-paths.injectable"; +import type { ResourceApplyingStack, ResourceStackDependencies } from "./resource-stack"; +import { ResourceStack } from "./resource-stack"; + +export type CreateResourceStack = (cluster: KubernetesCluster, name: string) => ResourceApplyingStack; + +const createResourceStackInjectable = getInjectable({ + id: "create-resource-stack", + instantiate: (di): CreateResourceStack => { + const deps: ResourceStackDependencies = { + joinPaths: di.inject(joinPathsInjectable), + kubectlApplyAll: di.inject(kubectlApplyAllInjectionToken), + kubectlDeleteAll: di.inject(kubectlDeleteAllInjectionToken), + logger: di.inject(loggerInjectable), + readDirectory: di.inject(readDirectoryInjectable), + readFile: di.inject(readFileInjectable), + }; + + return (cluster, name) => new ResourceStack(deps, cluster, name); + }, +}); + +export default createResourceStackInjectable; diff --git a/src/common/k8s/resource-stack.ts b/src/common/k8s/resource-stack.ts index d289a375b3..771b48b413 100644 --- a/src/common/k8s/resource-stack.ts +++ b/src/common/k8s/resource-stack.ts @@ -2,20 +2,39 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import fse from "fs-extra"; -import path from "path"; import hb from "handlebars"; -import { ResourceApplier } from "../../main/resource-applier"; import type { KubernetesCluster } from "../catalog-entities"; -import logger from "../../main/logger"; -import { app } from "electron"; -import { ClusterStore } from "../cluster-store/cluster-store"; import yaml from "js-yaml"; -import { productName } from "../vars"; -import { requestKubectlApplyAll, requestKubectlDeleteAll } from "../../renderer/ipc"; +import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; +import productNameInjectable from "../vars/product-name.injectable"; +import type { AsyncResult } from "../utils/async-result"; +import type { Logger } from "../logger"; +import type { KubectlApplyAll, KubectlDeleteAll } from "../kube-helpers/channels"; +import type { ReadDirectory } from "../fs/read-directory.injectable"; +import type { JoinPaths } from "../path/join-paths.injectable"; +import type { ReadFile } from "../fs/read-file.injectable"; +import { hasTypedProperty, isObject } from "../utils"; + +export interface ResourceApplyingStack { + kubectlApplyFolder(folderPath: string, templateContext?: any, extraArgs?: string[]): Promise; + kubectlDeleteFolder(folderPath: string, templateContext?: any, extraArgs?: string[]): Promise; +} + +export interface ResourceStackDependencies { + readonly logger: Logger; + kubectlApplyAll: KubectlApplyAll; + kubectlDeleteAll: KubectlDeleteAll; + readDirectory: ReadDirectory; + joinPaths: JoinPaths; + readFile: ReadFile; +} export class ResourceStack { - constructor(protected cluster: KubernetesCluster, protected name: string) {} + constructor( + protected readonly dependencies: ResourceStackDependencies, + protected readonly cluster: KubernetesCluster, + protected readonly name: string, + ) {} /** * @@ -24,8 +43,15 @@ export class ResourceStack { */ async kubectlApplyFolder(folderPath: string, templateContext?: any, extraArgs?: string[]): Promise { const resources = await this.renderTemplates(folderPath, templateContext); + const result = await this.applyResources(resources, extraArgs); - return this.applyResources(resources, extraArgs); + if (result.callWasSuccessful) { + return result.response; + } + + this.dependencies.logger.warn(`[RESOURCE-STACK]: failed to apply resources: ${result.error}`); + + return ""; } /** @@ -35,79 +61,60 @@ export class ResourceStack { */ async kubectlDeleteFolder(folderPath: string, templateContext?: any, extraArgs?: string[]): Promise { const resources = await this.renderTemplates(folderPath, templateContext); + const result = await this.deleteResources(resources, extraArgs); - return this.deleteResources(resources, extraArgs); + if (result.callWasSuccessful) { + return result.response; + } + + this.dependencies.logger.warn(`[RESOURCE-STACK]: failed to delete resources: ${result.error}`); + + return ""; } - protected async applyResources(resources: string[], extraArgs?: string[]): Promise { - const clusterModel = ClusterStore.getInstance().getById(this.cluster.getId()); + protected async applyResources(resources: string[], extraArgs: string[] = []): Promise> { + const kubectlArgs = [...extraArgs, ...this.getAdditionalArgs(extraArgs)]; - if (!clusterModel) { - throw new Error(`cluster not found`); - } - - let kubectlArgs = extraArgs || []; - - kubectlArgs = this.appendKubectlArgs(kubectlArgs); - - if (app) { - return await new ResourceApplier(clusterModel).kubectlApplyAll(resources, kubectlArgs); - } else { - const response = await requestKubectlApplyAll(this.cluster.getId(), resources, kubectlArgs); - - if (response.stderr) { - throw new Error(response.stderr); - } - - return response.stdout ?? ""; - } + return this.dependencies.kubectlApplyAll({ + clusterId: this.cluster.getId(), + resources, + extraArgs: kubectlArgs, + }); } - protected async deleteResources(resources: string[], extraArgs?: string[]): Promise { - const clusterModel = ClusterStore.getInstance().getById(this.cluster.getId()); + protected async deleteResources(resources: string[], extraArgs: string[] = []): Promise> { + const kubectlArgs = [...extraArgs, ...this.getAdditionalArgs(extraArgs)]; - if (!clusterModel) { - throw new Error(`cluster not found`); - } - - let kubectlArgs = extraArgs || []; - - kubectlArgs = this.appendKubectlArgs(kubectlArgs); - - if (app) { - return await new ResourceApplier(clusterModel).kubectlDeleteAll(resources, kubectlArgs); - } else { - const response = await requestKubectlDeleteAll(this.cluster.getId(), resources, kubectlArgs); - - if (response.stderr) { - throw new Error(response.stderr); - } - - return response.stdout ?? ""; - } + return this.dependencies.kubectlDeleteAll({ + clusterId: this.cluster.getId(), + resources, + extraArgs: kubectlArgs, + }); } - protected appendKubectlArgs(kubectlArgs: string[]) { + protected getAdditionalArgs(kubectlArgs: string[]): string[] { if (!kubectlArgs.includes("-l") && !kubectlArgs.includes("--label")) { - return kubectlArgs.concat(["-l", `app.kubernetes.io/name=${this.name}`]); + return ["-l", `app.kubernetes.io/name=${this.name}`]; } - return kubectlArgs; + return []; } protected async renderTemplates(folderPath: string, templateContext: any): Promise { const resources: string[] = []; + const di = getLegacyGlobalDiForExtensionApi(); + const productName = di.inject(productNameInjectable); - logger.info(`[RESOURCE-STACK]: render templates from ${folderPath}`); - const files = await fse.readdir(folderPath); + this.dependencies.logger.info(`[RESOURCE-STACK]: render templates from ${folderPath}`); + const files = await this.dependencies.readDirectory(folderPath); - for(const filename of files) { - const file = path.join(folderPath, filename); - const raw = await fse.readFile(file); + for (const filename of files) { + const file = this.dependencies.joinPaths(folderPath, filename); + const raw = await this.dependencies.readFile(file); const data = ( filename.endsWith(".hb") - ? hb.compile(raw.toString())(templateContext) - : raw.toString() + ? hb.compile(raw)(templateContext) + : raw ).trim(); if (!data) { @@ -119,16 +126,15 @@ export class ResourceStack { continue; } - const resource = entry as Record; + if (hasTypedProperty(entry, "metadata", isObject)) { + const labels = (entry.metadata.labels ??= {}) as Partial>; - if (typeof resource.metadata === "object") { - resource.metadata.labels ??= {}; - resource.metadata.labels["app.kubernetes.io/name"] = this.name; - resource.metadata.labels["app.kubernetes.io/managed-by"] = productName; - resource.metadata.labels["app.kubernetes.io/created-by"] = "resource-stack"; + labels["app.kubernetes.io/name"] = this.name; + labels["app.kubernetes.io/managed-by"] = productName; + labels["app.kubernetes.io/created-by"] = "resource-stack"; } - resources.push(yaml.dump(resource)); + resources.push(yaml.dump(entry)); } } diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts index f24d4dbe2d..dc388efbc8 100644 --- a/src/common/kube-helpers.ts +++ b/src/common/kube-helpers.ts @@ -4,31 +4,14 @@ */ import { KubeConfig } from "@kubernetes/client-node"; -import fse from "fs-extra"; -import path from "path"; -import os from "os"; import yaml from "js-yaml"; import logger from "../main/logger"; import type { Cluster, Context, User } from "@kubernetes/client-node/dist/config_types"; import { newClusters, newContexts, newUsers } from "@kubernetes/client-node/dist/config_types"; -import { isDefined, resolvePath } from "./utils"; +import { isDefined } from "./utils"; import Joi from "joi"; import type { PartialDeep } from "type-fest"; -export const kubeConfigDefaultPath = path.join(os.homedir(), ".kube", "config"); - -export function loadConfigFromFileSync(filePath: string): ConfigResult { - const content = fse.readFileSync(resolvePath(filePath), "utf-8"); - - return loadConfigFromString(content); -} - -export async function loadConfigFromFile(filePath: string): Promise { - const content = await fse.readFile(resolvePath(filePath), "utf-8"); - - return loadConfigFromString(content); -} - const clusterSchema = Joi.object({ name: Joi .string() diff --git a/src/common/kube-helpers/channels.ts b/src/common/kube-helpers/channels.ts new file mode 100644 index 0000000000..4782f64367 --- /dev/null +++ b/src/common/kube-helpers/channels.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { Asyncify } from "type-fest"; +import type { RequestChannelHandler } from "../../main/utils/channel/channel-listeners/listener-tokens"; +import type { ClusterId } from "../cluster-types"; +import type { AsyncResult } from "../utils/async-result"; +import type { RequestChannel } from "../utils/channel/request-channel-listener-injection-token"; + +export interface KubectlApplyAllArgs { + clusterId: ClusterId; + resources: string[]; + extraArgs: string[]; +} + +export const kubectlApplyAllChannel: RequestChannel> = { + id: "kubectl-apply-all", +}; + +export type KubectlApplyAll = Asyncify>; + +export const kubectlApplyAllInjectionToken = getInjectionToken({ + id: "kubectl-apply-all", +}); + +export interface KubectlDeleteAllArgs { + clusterId: ClusterId; + resources: string[]; + extraArgs: string[]; +} + +export const kubectlDeleteAllChannel: RequestChannel> = { + id: "kubectl-delete-all", +}; + +export type KubectlDeleteAll = Asyncify>; + +export const kubectlDeleteAllInjectionToken = getInjectionToken({ + id: "kubectl-delete-all", +}); diff --git a/src/common/kube-helpers/load-config-from-file.injectable.ts b/src/common/kube-helpers/load-config-from-file.injectable.ts new file mode 100644 index 0000000000..afa9d3c070 --- /dev/null +++ b/src/common/kube-helpers/load-config-from-file.injectable.ts @@ -0,0 +1,23 @@ +/** + * 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 readFileInjectable from "../fs/read-file.injectable"; +import type { ConfigResult } from "../kube-helpers"; +import { loadConfigFromString } from "../kube-helpers"; +import resolveTildeInjectable from "../path/resolve-tilde.injectable"; + +export type LoadConfigfromFile = (filePath: string) => Promise; + +const loadConfigfromFileInjectable = getInjectable({ + id: "load-configfrom-file", + instantiate: (di): LoadConfigfromFile => { + const readFile = di.inject(readFileInjectable); + const resolveTilde = di.inject(resolveTildeInjectable); + + return async (filePath) => loadConfigFromString(await readFile(resolveTilde(filePath))); + }, +}); + +export default loadConfigfromFileInjectable; diff --git a/src/common/log-error.global-override-for-injectable.ts b/src/common/log-error.global-override-for-injectable.ts new file mode 100644 index 0000000000..e3a03c2802 --- /dev/null +++ b/src/common/log-error.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getGlobalOverrideForFunction } from "./test-utils/get-global-override-for-function"; +import logErrorInjectable from "./log-error.injectable"; + +// Note: this should remain as it is, and throw if called. Logging error is something +// that cannot happen without a unit test explicitly causing it. It cannot be allowed +// to happen without author of unit test knowing it. +export default getGlobalOverrideForFunction(logErrorInjectable); diff --git a/src/common/logger-transports/sentry.ts b/src/common/logger-transports/sentry.ts deleted file mode 100644 index b5855c42f8..0000000000 --- a/src/common/logger-transports/sentry.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import Transport from "winston-transport"; -import { LEVEL } from "triple-beam"; -import { Severity } from "@sentry/browser"; -import * as Sentry from "@sentry/electron"; - -const SENTRY_LEVELS_MAP = { - silly: Severity.Debug, - verbose: Severity.Debug, - debug: Severity.Debug, - info: Severity.Info, - warn: Severity.Warning, - error: Severity.Error, -}; -const WINSTON_CMP: Record> = { - silly: new Set(["silly", "verbose", "debug", "info", "warn", "error"]), - verbose: new Set(["verbose", "debug", "info", "warn", "error"]), - debug: new Set(["debug", "info", "warn", "error"]), - info: new Set(["info", "warn", "error"]), - warn: new Set(["warn", "error"]), - error: new Set(["error"]), -}; - -export type WinstonLevel = keyof typeof SENTRY_LEVELS_MAP; - -export class SentryTransport extends Transport { - logLevels: Set; - - constructor(minWinstonLevel: WinstonLevel) { - super(); - - this.logLevels = WINSTON_CMP[minWinstonLevel]; - } - - log(info: any, next: () => void) { - setImmediate(() => { - this.emit("logged", info); - }); - - const { message, level: _, tags, user, ...extra } = info; - const winstonLevel: WinstonLevel = info[LEVEL]; - const level = SENTRY_LEVELS_MAP[winstonLevel]; - - try { - if (this.logLevels.has(winstonLevel)) { - Sentry.captureMessage(message, { - level, - tags, - extra, - }); - } - } finally { - next(); - } - } -} diff --git a/src/common/logger.injectable.ts b/src/common/logger.injectable.ts index 55e2f26b2a..e1a085f199 100644 --- a/src/common/logger.injectable.ts +++ b/src/common/logger.injectable.ts @@ -3,12 +3,19 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; +import { createLogger, format } from "winston"; import type { Logger } from "./logger"; -import logger from "./logger"; +import { loggerTransportInjectionToken } from "./logger/transports"; const loggerInjectable = getInjectable({ id: "logger", - instantiate: (): Logger => logger, + instantiate: (di): Logger => createLogger({ + format: format.combine( + format.splat(), + format.simple(), + ), + transports: di.injectMany(loggerTransportInjectionToken), + }), }); export default loggerInjectable; diff --git a/src/common/logger.ts b/src/common/logger.ts index fd9345d61e..948404a6b9 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -3,12 +3,8 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { app, ipcMain } from "electron"; -import winston, { format } from "winston"; -import type Transport from "winston-transport"; -import { consoleFormat } from "winston-console-format"; -import { isDebugging, isTestEnv } from "./vars"; -import BrowserConsole from "winston-transport-browserconsole"; +import { asLegacyGlobalForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import loggerInjectable from "./logger.injectable"; export interface Logger { info: (message: string, ...args: any) => void; @@ -18,64 +14,7 @@ export interface Logger { silly: (message: string, ...args: any) => void; } -const logLevel = process.env.LOG_LEVEL - ? process.env.LOG_LEVEL - : isDebugging - ? "debug" - : isTestEnv - ? "error" - : "info"; - -const transports: Transport[] = []; - -if (ipcMain) { - transports.push( - new winston.transports.Console({ - handleExceptions: false, - level: logLevel, - format: format.combine( - format.colorize({ level: true, message: false }), - format.padLevels(), - format.ms(), - consoleFormat({ - showMeta: true, - inspectOptions: { - depth: 4, - colors: true, - maxArrayLength: 10, - breakLength: 120, - compact: Infinity, - }, - }), - ), - }), - ); - - if (!isTestEnv) { - transports.push( - new winston.transports.File({ - handleExceptions: false, - level: logLevel, - filename: "lens.log", - /** - * SAFTEY: the `ipcMain` check above should mean that this is only - * called in the main process - */ - dirname: app.getPath("logs"), - maxsize: 16 * 1024, - maxFiles: 16, - tailable: true, - }), - ); - } -} else { - transports.push(new BrowserConsole()); -} - -export default winston.createLogger({ - format: format.combine( - format.splat(), - format.simple(), - ), - transports, -}) as Logger; +/** + * @deprecated use `di.inject(loggerInjectable)` instead + */ +export default asLegacyGlobalForExtensionApi(loggerInjectable); diff --git a/src/common/logger/prefixed-logger.injectable.ts b/src/common/logger/prefixed-logger.injectable.ts new file mode 100644 index 0000000000..36f86c532f --- /dev/null +++ b/src/common/logger/prefixed-logger.injectable.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { Logger } from "../logger"; +import loggerInjectable from "../logger.injectable"; + +const prefixedLoggerInjectable = getInjectable({ + id: "prefixed-logger", + instantiate: (di, prefix): Logger => { + const logger = di.inject(loggerInjectable); + + return { + debug: (message, ...args) => { + logger.debug(`[${prefix}]: ${message}`, ...args); + }, + error: (message, ...args) => { + logger.error(`[${prefix}]: ${message}`, ...args); + }, + info: (message, ...args) => { + logger.info(`[${prefix}]: ${message}`, ...args); + }, + silly: (message, ...args) => { + logger.silly(`[${prefix}]: ${message}`, ...args); + }, + warn: (message, ...args) => { + logger.warn(`[${prefix}]: ${message}`, ...args); + }, + }; + }, + lifecycle: lifecycleEnum.keyedSingleton({ + getInstanceKey: (di, prefix: string) => prefix, + }), +}); + +export default prefixedLoggerInjectable; diff --git a/src/common/logger/transports.ts b/src/common/logger/transports.ts new file mode 100644 index 0000000000..1407eb91b8 --- /dev/null +++ b/src/common/logger/transports.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import type TransportStream from "winston-transport"; + +export const loggerTransportInjectionToken = getInjectionToken({ + id: "logger-transport", +}); diff --git a/src/common/os/home-directory-path.injectable.ts b/src/common/os/home-directory-path.injectable.ts new file mode 100644 index 0000000000..83b4b0cdff --- /dev/null +++ b/src/common/os/home-directory-path.injectable.ts @@ -0,0 +1,13 @@ +/** + * 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 userInfoInjectable from "../user-store/user-info.injectable"; + +const homeDirectoryPathInjectable = getInjectable({ + id: "home-directory-path", + instantiate: (di) => di.inject(userInfoInjectable).homedir, +}); + +export default homeDirectoryPathInjectable; diff --git a/src/common/os/temp-directory-path.global-override-for-injectable.ts b/src/common/os/temp-directory-path.global-override-for-injectable.ts new file mode 100644 index 0000000000..05615644f9 --- /dev/null +++ b/src/common/os/temp-directory-path.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import tempDirectoryPathInjectable from "./temp-directory-path.injectable"; + +export default getGlobalOverride(tempDirectoryPathInjectable, () => "/some-temp-directory"); diff --git a/src/common/vars/package-json.injectable.ts b/src/common/os/temp-directory-path.injectable.ts similarity index 55% rename from src/common/vars/package-json.injectable.ts rename to src/common/os/temp-directory-path.injectable.ts index fa132be518..46fc5db67d 100644 --- a/src/common/vars/package-json.injectable.ts +++ b/src/common/os/temp-directory-path.injectable.ts @@ -3,12 +3,12 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import packageJson from "../../../package.json"; +import { tmpdir } from "os"; -const packageJsonInjectable = getInjectable({ - id: "package-json", - instantiate: () => packageJson, +const tempDirectoryPathInjectable = getInjectable({ + id: "temp-directory-path", + instantiate: () => tmpdir(), causesSideEffects: true, }); -export default packageJsonInjectable; +export default tempDirectoryPathInjectable; diff --git a/src/common/path/get-absolute-path.global-override-for-injectable.ts b/src/common/path/get-absolute-path.global-override-for-injectable.ts new file mode 100644 index 0000000000..15f377cb2c --- /dev/null +++ b/src/common/path/get-absolute-path.global-override-for-injectable.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import path from "path"; +import { getGlobalOverride } from "../test-utils/get-global-override"; +import getAbsolutePathInjectable from "./get-absolute-path.injectable"; + +export default getGlobalOverride(getAbsolutePathInjectable, () => path.posix.resolve); diff --git a/src/common/path/get-basename.global-override-for-injectable.ts b/src/common/path/get-basename.global-override-for-injectable.ts new file mode 100644 index 0000000000..913ec9c5c2 --- /dev/null +++ b/src/common/path/get-basename.global-override-for-injectable.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import path from "path"; +import { getGlobalOverride } from "../test-utils/get-global-override"; +import getBasenameOfPathInjectable from "./get-basename.injectable"; + +export default getGlobalOverride(getBasenameOfPathInjectable, () => path.posix.basename); diff --git a/src/common/path/get-basename.injectable.ts b/src/common/path/get-basename.injectable.ts new file mode 100644 index 0000000000..be92bde7f5 --- /dev/null +++ b/src/common/path/get-basename.injectable.ts @@ -0,0 +1,16 @@ +/** + * 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 path from "path"; + +export type GetBasenameOfPath = (path: string) => string; + +const getBasenameOfPathInjectable = getInjectable({ + id: "get-basename-of-path", + instantiate: (): GetBasenameOfPath => path.basename, + causesSideEffects: true, +}); + +export default getBasenameOfPathInjectable; diff --git a/src/common/path/get-dirname.global-override-for-injectable.ts b/src/common/path/get-dirname.global-override-for-injectable.ts new file mode 100644 index 0000000000..ed694de182 --- /dev/null +++ b/src/common/path/get-dirname.global-override-for-injectable.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import path from "path"; +import { getGlobalOverride } from "../test-utils/get-global-override"; +import getDirnameOfPathInjectable from "./get-dirname.injectable"; + +export default getGlobalOverride(getDirnameOfPathInjectable, () => path.posix.dirname); diff --git a/src/common/path/get-dirname.injectable.ts b/src/common/path/get-dirname.injectable.ts new file mode 100644 index 0000000000..93b4496767 --- /dev/null +++ b/src/common/path/get-dirname.injectable.ts @@ -0,0 +1,16 @@ +/** + * 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 path from "path"; + +export type GetDirnameOfPath = (path: string) => string; + +const getDirnameOfPathInjectable = getInjectable({ + id: "get-dirname-of-path", + instantiate: (): GetDirnameOfPath => path.dirname, + causesSideEffects: true, +}); + +export default getDirnameOfPathInjectable; diff --git a/src/common/path/get-relative-path.global-override-for-injectable.ts b/src/common/path/get-relative-path.global-override-for-injectable.ts new file mode 100644 index 0000000000..9e96b70301 --- /dev/null +++ b/src/common/path/get-relative-path.global-override-for-injectable.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import path from "path"; +import { getGlobalOverride } from "../test-utils/get-global-override"; +import getRelativePathInjectable from "./get-relative-path.injectable"; + +export default getGlobalOverride(getRelativePathInjectable, () => path.posix.relative); diff --git a/src/common/path/get-relative-path.injectable.ts b/src/common/path/get-relative-path.injectable.ts new file mode 100644 index 0000000000..18b5d832de --- /dev/null +++ b/src/common/path/get-relative-path.injectable.ts @@ -0,0 +1,16 @@ +/** + * 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 path from "path"; + +export type GetRelativePath = (from: string, to: string) => string; + +const getRelativePathInjectable = getInjectable({ + id: "get-relative-path", + instantiate: (): GetRelativePath => path.relative, + causesSideEffects: true, +}); + +export default getRelativePathInjectable; diff --git a/src/common/path/is-logical-child-path.injectable.ts b/src/common/path/is-logical-child-path.injectable.ts new file mode 100644 index 0000000000..1a52b66c0f --- /dev/null +++ b/src/common/path/is-logical-child-path.injectable.ts @@ -0,0 +1,51 @@ +/** + * 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 getAbsolutePathInjectable from "./get-absolute-path.injectable"; +import getDirnameOfPathInjectable from "./get-dirname.injectable"; + +/** + * Checks if `testPath` represents a potential filesystem entry that would be + * logically "within" the `parentPath` directory. + * + * This function will return `true` in the above case, and `false` otherwise. + * It will return `false` if the two paths are the same (after resolving them). + * + * The function makes no FS calls and is platform dependant. Meaning that the + * results are only guaranteed to be correct for the platform you are running + * on. + * @param parentPath The known path of a directory + * @param testPath The path that is to be tested + */ +export type IsLogicalChildPath = (parentPath: string, testPath: string) => boolean; + +const isLogicalChildPathInjectable = getInjectable({ + id: "is-logical-child-path", + instantiate: (di): IsLogicalChildPath => { + const getAbsolutePath = di.inject(getAbsolutePathInjectable); + const getDirnameOfPath = di.inject(getDirnameOfPathInjectable); + + return (parentPath, testPath) => { + const resolvedParentPath = getAbsolutePath(parentPath); + let resolvedTestPath = getAbsolutePath(testPath); + + if (resolvedParentPath === resolvedTestPath) { + return false; + } + + while (resolvedTestPath.length >= resolvedParentPath.length) { + if (resolvedTestPath === resolvedParentPath) { + return true; + } + + resolvedTestPath = getDirnameOfPath(resolvedTestPath); + } + + return false; + }; + }, +}); + +export default isLogicalChildPathInjectable; diff --git a/src/common/path/join-paths.global-override-for-injectable.ts b/src/common/path/join-paths.global-override-for-injectable.ts new file mode 100644 index 0000000000..d3e9d5e4c2 --- /dev/null +++ b/src/common/path/join-paths.global-override-for-injectable.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import path from "path"; +import { getGlobalOverride } from "../test-utils/get-global-override"; +import joinPathsInjectable from "./join-paths.injectable"; + +export default getGlobalOverride(joinPathsInjectable, () => path.posix.join); diff --git a/src/common/path/parse.global-override-for-injectable.ts b/src/common/path/parse.global-override-for-injectable.ts new file mode 100644 index 0000000000..fad97db696 --- /dev/null +++ b/src/common/path/parse.global-override-for-injectable.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import path from "path"; +import { getGlobalOverride } from "../test-utils/get-global-override"; +import parsePathInjectable from "./parse.injectable"; + +export default getGlobalOverride(parsePathInjectable, () => path.posix.parse); diff --git a/src/renderer/components/+preferences/sentry-dns-url.injectable.ts b/src/common/path/parse.injectable.ts similarity index 52% rename from src/renderer/components/+preferences/sentry-dns-url.injectable.ts rename to src/common/path/parse.injectable.ts index 5f337436fc..a32dfb3fa5 100644 --- a/src/renderer/components/+preferences/sentry-dns-url.injectable.ts +++ b/src/common/path/parse.injectable.ts @@ -3,11 +3,12 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { sentryDsn } from "../../../common/vars"; +import path from "path"; -const sentryDnsUrlInjectable = getInjectable({ - id: "sentry-dns-url", - instantiate: () => sentryDsn, +const parsePathInjectable = getInjectable({ + id: "parse-path", + instantiate: () => path.parse, + causesSideEffects: true, }); -export default sentryDnsUrlInjectable; +export default parsePathInjectable; diff --git a/src/common/path/resolve-path.injectable.ts b/src/common/path/resolve-path.injectable.ts new file mode 100644 index 0000000000..75a1e98c59 --- /dev/null +++ b/src/common/path/resolve-path.injectable.ts @@ -0,0 +1,21 @@ +/** + * 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 getAbsolutePathInjectable from "./get-absolute-path.injectable"; +import resolveTildeInjectable from "./resolve-tilde.injectable"; + +export type ResolvePath = (path: string) => string; + +const resolvePathInjectable = getInjectable({ + id: "resolve-path", + instantiate: (di): ResolvePath => { + const getAbsolutePath = di.inject(getAbsolutePathInjectable); + const resolveTilde = di.inject(resolveTildeInjectable); + + return (filePath) => getAbsolutePath(resolveTilde(filePath)); + }, +}); + +export default resolvePathInjectable; diff --git a/src/common/path/resolve-tilde.injectable.ts b/src/common/path/resolve-tilde.injectable.ts new file mode 100644 index 0000000000..86d267aa4f --- /dev/null +++ b/src/common/path/resolve-tilde.injectable.ts @@ -0,0 +1,31 @@ +/** + * 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 homeDirectoryPathInjectable from "../os/home-directory-path.injectable"; +import fileSystemSeparatorInjectable from "./separator.injectable"; + +export type ResolveTilde = (path: string) => string; + +const resolveTildeInjectable = getInjectable({ + id: "resolve-tilde", + instantiate: (di): ResolveTilde => { + const homeDirectoryPath = di.inject(homeDirectoryPathInjectable); + const fileSystemSeparator = di.inject(fileSystemSeparatorInjectable); + + return (filePath) => { + if (filePath === "~") { + return homeDirectoryPath; + } + + if (filePath === `~${fileSystemSeparator}`) { + return `${homeDirectoryPath}${filePath.slice(1)}`; + } + + return filePath; + }; + }, +}); + +export default resolveTildeInjectable; diff --git a/src/common/path/separator.global-override-for-injectable.ts b/src/common/path/separator.global-override-for-injectable.ts new file mode 100644 index 0000000000..655f8908b0 --- /dev/null +++ b/src/common/path/separator.global-override-for-injectable.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import path from "path"; +import { getGlobalOverride } from "../test-utils/get-global-override"; +import fileSystemSeparatorInjectable from "./separator.injectable"; + +export default getGlobalOverride(fileSystemSeparatorInjectable, () => path.posix.sep); diff --git a/src/common/path/separator.injectable.ts b/src/common/path/separator.injectable.ts new file mode 100644 index 0000000000..5b0413b56f --- /dev/null +++ b/src/common/path/separator.injectable.ts @@ -0,0 +1,14 @@ +/** + * 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 path from "path"; + +const fileSystemSeparatorInjectable = getInjectable({ + id: "file-system-separator", + instantiate: () => path.sep, + causesSideEffects: true, +}); + +export default fileSystemSeparatorInjectable; diff --git a/src/common/rbac.ts b/src/common/rbac.ts index 965fff5e46..bfa04ef46b 100644 --- a/src/common/rbac.ts +++ b/src/common/rbac.ts @@ -4,10 +4,11 @@ */ export type KubeResource = - "namespaces" | "nodes" | "events" | "resourcequotas" | "services" | "limitranges" | + "namespaces" | "nodes" | "events" | "resourcequotas" | "services" | "limitranges" | "leases" | "secrets" | "configmaps" | "ingresses" | "networkpolicies" | "persistentvolumeclaims" | "persistentvolumes" | "storageclasses" | "pods" | "daemonsets" | "deployments" | "statefulsets" | "replicasets" | "jobs" | "cronjobs" | - "endpoints" | "customresourcedefinitions" | "horizontalpodautoscalers" | "podsecuritypolicies" | "poddisruptionbudgets" | "priorityclasses" | + "endpoints" | "customresourcedefinitions" | "horizontalpodautoscalers" | "podsecuritypolicies" | "poddisruptionbudgets" | + "priorityclasses" | "runtimeclasses" | "roles" | "clusterroles" | "rolebindings" | "clusterrolebindings" | "serviceaccounts"; export interface KubeApiResource extends KubeApiResourceData { @@ -34,6 +35,7 @@ export const apiResourceRecord: Record = { "jobs": { kind: "Job", group: "batch" }, "namespaces": { kind: "Namespace" }, "limitranges": { kind: "LimitRange" }, + "leases": { kind: "Lease" }, "networkpolicies": { kind: "NetworkPolicy", group: "networking.k8s.io" }, "nodes": { kind: "Node" }, "persistentvolumes": { kind: "PersistentVolume" }, @@ -42,6 +44,7 @@ export const apiResourceRecord: Record = { "poddisruptionbudgets": { kind: "PodDisruptionBudget", group: "policy" }, "podsecuritypolicies": { kind: "PodSecurityPolicy", group: "policy" }, "priorityclasses": { kind: "PriorityClass", group: "scheduling.k8s.io" }, + "runtimeclasses": { kind: "RuntimeClass", group: "node.k8s.io" }, "resourcequotas": { kind: "ResourceQuota" }, "replicasets": { kind: "ReplicaSet", group: "apps" }, "roles": { kind: "Role", group: "rbac.authorization.k8s.io" }, diff --git a/src/common/root-frame-rendered-channel/root-frame-rendered-channel.injectable.ts b/src/common/root-frame-rendered-channel/root-frame-rendered-channel.injectable.ts deleted file mode 100644 index a7787c6cc4..0000000000 --- a/src/common/root-frame-rendered-channel/root-frame-rendered-channel.injectable.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * 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 type { MessageChannel } from "../utils/channel/message-channel-injection-token"; -import { messageChannelInjectionToken } from "../utils/channel/message-channel-injection-token"; - -export type RootFrameRenderedChannel = MessageChannel; - -const rootFrameRenderedChannelInjectable = getInjectable({ - id: "root-frame-rendered-channel", - - instantiate: (): RootFrameRenderedChannel => ({ - id: "root-frame-rendered", - }), - - injectionToken: messageChannelInjectionToken, -}); - -export default rootFrameRenderedChannelInjectable; diff --git a/src/common/root-frame/root-frame-rendered-channel.ts b/src/common/root-frame/root-frame-rendered-channel.ts new file mode 100644 index 0000000000..060ae8735c --- /dev/null +++ b/src/common/root-frame/root-frame-rendered-channel.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { MessageChannel } from "../utils/channel/message-channel-listener-injection-token"; + +export type RootFrameHasRenderedChannel = MessageChannel; + +export const rootFrameHasRenderedChannel: RootFrameHasRenderedChannel = { + id: "root-frame-rendered", +}; diff --git a/src/common/runnable/run-many-for.test.ts b/src/common/runnable/run-many-for.test.ts index 35db17952a..c2cc152681 100644 --- a/src/common/runnable/run-many-for.test.ts +++ b/src/common/runnable/run-many-for.test.ts @@ -25,13 +25,19 @@ describe("runManyFor", () => { const someInjectable = getInjectable({ id: "some-injectable", - instantiate: () => ({ run: () => runMock("some-call") }), + instantiate: () => ({ + id: "some-injectable", + run: () => runMock("some-call"), + }), injectionToken: someInjectionTokenForRunnables, }); const someOtherInjectable = getInjectable({ id: "some-other-injectable", - instantiate: () => ({ run: () => runMock("some-other-call") }), + instantiate: () => ({ + id: "some-other-injectable", + run: () => runMock("some-other-call"), + }), injectionToken: someInjectionTokenForRunnables, }); @@ -79,6 +85,7 @@ describe("runManyFor", () => { id: "some-injectable-1", instantiate: (di) => ({ + id: "some-injectable-1", run: () => runMock("third-level-run"), runAfter: di.inject(someInjectable2), }), @@ -90,6 +97,7 @@ describe("runManyFor", () => { id: "some-injectable-2", instantiate: (di) => ({ + id: "some-injectable-2", run: () => runMock("second-level-run"), runAfter: di.inject(someInjectable3), }), @@ -99,7 +107,10 @@ describe("runManyFor", () => { const someInjectable3 = getInjectable({ id: "some-injectable-3", - instantiate: () => ({ run: () => runMock("first-level-run") }), + instantiate: () => ({ + id: "some-injectable-3", + run: () => runMock("first-level-run"), + }), injectionToken: someInjectionTokenForRunnables, }); @@ -186,6 +197,7 @@ describe("runManyFor", () => { id: "some-runnable-1", instantiate: (di) => ({ + id: "some-runnable-1", run: () => runMock("some-runnable-1"), runAfter: di.inject(someOtherInjectable), }), @@ -197,6 +209,7 @@ describe("runManyFor", () => { id: "some-runnable-2", instantiate: () => ({ + id: "some-runnable-2", run: () => runMock("some-runnable-2"), }), @@ -210,7 +223,7 @@ describe("runManyFor", () => { ); return expect(() => runMany()).rejects.toThrow( - "Tried to run runnable after other runnable which does not same injection token.", + /Tried to get a composite but encountered missing parent ids: "some-runnable-2".\n\nAvailable parent ids are:\n"[0-9a-z-]+",\n"some-runnable-1"/, ); }); @@ -232,6 +245,7 @@ describe("runManyFor", () => { id: "some-runnable-1", instantiate: () => ({ + id: "some-runnable-1", run: (parameter) => runMock("run-of-some-runnable-1", parameter), }), @@ -242,6 +256,7 @@ describe("runManyFor", () => { id: "some-runnable-2", instantiate: () => ({ + id: "some-runnable-2", run: (parameter) => runMock("run-of-some-runnable-2", parameter), }), diff --git a/src/common/runnable/run-many-for.ts b/src/common/runnable/run-many-for.ts index f2c1a4ae56..ce3123c0b0 100644 --- a/src/common/runnable/run-many-for.ts +++ b/src/common/runnable/run-many-for.ts @@ -2,50 +2,47 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { pipeline } from "@ogre-tools/fp"; -import type { - DiContainerForInjection, - InjectionToken, -} from "@ogre-tools/injectable"; -import { filter, forEach, map, tap } from "lodash/fp"; -import { throwWithIncorrectHierarchyFor } from "./throw-with-incorrect-hierarchy-for"; +import type { DiContainerForInjection, InjectionToken } from "@ogre-tools/injectable"; +import type { Composite } from "../utils/composite/get-composite/get-composite"; +import { getCompositeFor } from "../utils/composite/get-composite/get-composite"; +import * as uuid from "uuid"; export interface Runnable { + id: string; run: Run; - runAfter?: this; + runAfter?: Runnable; } type Run = (parameter: Param) => Promise | void; -export type RunMany = ( - injectionToken: InjectionToken, void> -) => Run; +export type RunMany = (injectionToken: InjectionToken, void>) => Run; + +async function runCompositeRunnables(param: Param, composite: Composite>) { + await composite.value.run(param); + await Promise.all(composite.children.map(composite => runCompositeRunnables(param, composite))); +} export function runManyFor(di: DiContainerForInjection): RunMany { - return (injectionToken) => async (parameter) => { + return (injectionToken: InjectionToken, void>) => async (param: Param) => { const allRunnables = di.injectMany(injectionToken); + const rootId = uuid.v4(); + const getCompositeRunnables = getCompositeFor>({ + getId: (runnable) => runnable.id, + getParentId: (runnable) => ( + runnable.id === rootId + ? undefined + : runnable.runAfter?.id ?? rootId + ), + }); + const composite = getCompositeRunnables([ + // This is a dummy runnable to conform to the requirements of `getCompositeFor` to only have one root + { + id: rootId, + run: () => {}, + }, + ...allRunnables, + ]); - const throwWithIncorrectHierarchy = throwWithIncorrectHierarchyFor(allRunnables); - - const recursedRun = async ( - runAfterRunnable: Runnable | undefined = undefined, - ) => - await pipeline( - allRunnables, - - tap(runnables => forEach(throwWithIncorrectHierarchy, runnables)), - - filter((runnable) => runnable.runAfter === runAfterRunnable), - - map(async (runnable) => { - await runnable.run(parameter); - - await recursedRun(runnable); - }), - - (promises) => Promise.all(promises), - ); - - await recursedRun(); + await runCompositeRunnables(param, composite); }; } diff --git a/src/common/runnable/run-many-sync-for.test.ts b/src/common/runnable/run-many-sync-for.test.ts index fe47516725..3aadcad0bf 100644 --- a/src/common/runnable/run-many-sync-for.test.ts +++ b/src/common/runnable/run-many-sync-for.test.ts @@ -21,13 +21,19 @@ describe("runManySyncFor", () => { const someInjectable = getInjectable({ id: "some-injectable", - instantiate: () => ({ run: () => runMock("some-call") }), + instantiate: () => ({ + id: "some-injectable", + run: () => runMock("some-call"), + }), injectionToken: someInjectionTokenForRunnables, }); const someOtherInjectable = getInjectable({ id: "some-other-injectable", - instantiate: () => ({ run: () => runMock("some-other-call") }), + instantiate: () => ({ + id: "some-other-injectable", + run: () => runMock("some-other-call"), + }), injectionToken: someInjectionTokenForRunnables, }); @@ -62,7 +68,8 @@ describe("runManySyncFor", () => { id: "some-injectable-1", instantiate: (di) => ({ - run: () => runMock("third-level-run"), + id: "some-injectable-1", + run: () => void runMock("third-level-run"), runAfter: di.inject(someInjectable2), }), @@ -73,7 +80,8 @@ describe("runManySyncFor", () => { id: "some-injectable-2", instantiate: (di) => ({ - run: () => runMock("second-level-run"), + id: "some-injectable-2", + run: () => void runMock("second-level-run"), runAfter: di.inject(someInjectable3), }), @@ -82,7 +90,10 @@ describe("runManySyncFor", () => { const someInjectable3 = getInjectable({ id: "some-injectable-3", - instantiate: () => ({ run: () => runMock("first-level-run") }), + instantiate: () => ({ + id: "some-injectable-3", + run: () => void runMock("first-level-run"), + }), injectionToken: someInjectionTokenForRunnables, }); @@ -115,6 +126,7 @@ describe("runManySyncFor", () => { id: "some-runnable-1", instantiate: (di) => ({ + id: "some-runnable-1", run: () => runMock("some-runnable-1"), runAfter: di.inject(someOtherInjectable), }), @@ -126,6 +138,7 @@ describe("runManySyncFor", () => { id: "some-runnable-2", instantiate: () => ({ + id: "some-runnable-2", run: () => runMock("some-runnable-2"), }), @@ -138,13 +151,13 @@ describe("runManySyncFor", () => { someInjectionToken, ); - return expect(() => runMany()).rejects.toThrow( - "Tried to run runnable after other runnable which does not same injection token.", + return expect(() => runMany()).toThrow( + /Tried to get a composite but encountered missing parent ids: "some-runnable-2".\n\nAvailable parent ids are:\n"[0-9a-z-]+",\n"some-runnable-1"/, ); }); describe("when running many with parameter", () => { - let runMock: jest.Mock<(arg: string, arg2: string) => void>; + let runMock: jest.Mock<(arg: string, arg2: string) => undefined>; beforeEach(() => { const rootDi = createContainer("irrelevant"); @@ -161,7 +174,8 @@ describe("runManySyncFor", () => { id: "some-runnable-1", instantiate: () => ({ - run: (parameter) => runMock("run-of-some-runnable-1", parameter), + id: "some-runnable-1", + run: (parameter) => void runMock("run-of-some-runnable-1", parameter), }), injectionToken: someInjectionTokenForRunnablesWithParameter, @@ -171,7 +185,8 @@ describe("runManySyncFor", () => { id: "some-runnable-2", instantiate: () => ({ - run: (parameter) => runMock("run-of-some-runnable-2", parameter), + id: "some-runnable-2", + run: (parameter) => void runMock("run-of-some-runnable-2", parameter), }), injectionToken: someInjectionTokenForRunnablesWithParameter, diff --git a/src/common/runnable/run-many-sync-for.ts b/src/common/runnable/run-many-sync-for.ts index cfe93fa4b3..08dba2f72d 100644 --- a/src/common/runnable/run-many-sync-for.ts +++ b/src/common/runnable/run-many-sync-for.ts @@ -2,49 +2,54 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { pipeline } from "@ogre-tools/fp"; -import type { - DiContainerForInjection, - InjectionToken, -} from "@ogre-tools/injectable"; -import { filter, forEach, map, tap } from "lodash/fp"; -import type { Runnable } from "./run-many-for"; -import { throwWithIncorrectHierarchyFor } from "./throw-with-incorrect-hierarchy-for"; +import type { DiContainerForInjection, InjectionToken } from "@ogre-tools/injectable"; +import type { Composite } from "../utils/composite/get-composite/get-composite"; +import { getCompositeFor } from "../utils/composite/get-composite/get-composite"; +import * as uuid from "uuid"; export interface RunnableSync { + id: string; run: RunSync; - runAfter?: this; + runAfter?: RunnableSync; } -type RunSync = (parameter: Param) => void; +/** + * NOTE: this is the worse of two evils. This makes sure that `RunnableSync` always is sync. + * If the return type is `void` instead then async functions (those return `Promise`) can + * coerce to it. + */ +type RunSync = (parameter: Param) => undefined; -export type RunManySync = ( - injectionToken: InjectionToken, void> -) => RunSync; +export type RunManySync = (injectionToken: InjectionToken, void>) => RunSync; + +function runCompositeRunnableSyncs(param: Param, composite: Composite>): undefined { + composite.value.run(param); + composite.children.map(composite => runCompositeRunnableSyncs(param, composite)); + + return undefined; +} export function runManySyncFor(di: DiContainerForInjection): RunManySync { - return (injectionToken) => async (parameter) => { + return (injectionToken: InjectionToken, void>) => (param: Param): undefined => { const allRunnables = di.injectMany(injectionToken); + const rootId = uuid.v4(); + const getCompositeRunnables = getCompositeFor>({ + getId: (runnable) => runnable.id, + getParentId: (runnable) => ( + runnable.id === rootId + ? undefined + : runnable.runAfter?.id ?? rootId + ), + }); + const composite = getCompositeRunnables([ + // This is a dummy runnable to conform to the requirements of `getCompositeFor` to only have one root + { + id: rootId, + run: () => undefined, + }, + ...allRunnables, + ]); - const throwWithIncorrectHierarchy = throwWithIncorrectHierarchyFor(allRunnables); - - const recursedRun = ( - runAfterRunnable: RunnableSync | undefined = undefined, - ) => - pipeline( - allRunnables, - - tap(runnables => forEach(throwWithIncorrectHierarchy, runnables)), - - filter((runnable) => runnable.runAfter === runAfterRunnable), - - map((runnable) => { - runnable.run(parameter); - - recursedRun(runnable); - }), - ); - - recursedRun(); + return runCompositeRunnableSyncs(param, composite); }; } diff --git a/src/common/runnable/throw-with-incorrect-hierarchy-for.ts b/src/common/runnable/throw-with-incorrect-hierarchy-for.ts deleted file mode 100644 index 03073c4044..0000000000 --- a/src/common/runnable/throw-with-incorrect-hierarchy-for.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { Runnable } from "./run-many-for"; -import type { RunnableSync } from "./run-many-sync-for"; - -export const throwWithIncorrectHierarchyFor = - (allRunnables: Runnable[] | RunnableSync[]) => - (runnable: Runnable | RunnableSync) => { - if (runnable.runAfter && !allRunnables.includes(runnable.runAfter)) { - throw new Error( - "Tried to run runnable after other runnable which does not same injection token.", - ); - } - }; diff --git a/src/common/sentry.ts b/src/common/sentry.ts deleted file mode 100644 index 282afa6f32..0000000000 --- a/src/common/sentry.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { Dedupe, Offline } from "@sentry/integrations"; -import { sentryDsn, isProduction } from "./vars"; -import { UserStore } from "./user-store"; -import { inspect } from "util"; -import type { BrowserOptions } from "@sentry/electron/renderer"; -import type { ElectronMainOptions } from "@sentry/electron/main"; - -/** - * "Translate" 'browser' to 'main' as Lens developer more familiar with the term 'main' - */ -function mapProcessName(processType: string) { - if (processType === "browser") { - return "main"; - } - - return processType; -} - -/** - * Initialize Sentry for the current process so to send errors for debugging. - */ -export function initializeSentryReporting(init: (opts: BrowserOptions | ElectronMainOptions) => void) { - const processName = mapProcessName(process.type); - - if (!sentryDsn) { - return; // do nothing if not configured to avoid uncaught error in dev mode - } - - init({ - beforeSend: (event) => { - // default to false, in case instance of UserStore is not created (yet) - const allowErrorReporting = UserStore.getInstance(false)?.allowErrorReporting ?? false; - - if (allowErrorReporting) { - return event; - } - - /** - * Directly write to stdout so that no other integrations capture this and create an infinite loop - */ - process.stdout.write(`🔒 [SENTRY-BEFORE-SEND-HOOK]: allowErrorReporting: ${allowErrorReporting}. Sentry event is caught but not sent to server.`); - process.stdout.write("🔒 [SENTRY-BEFORE-SEND-HOOK]: === START OF SENTRY EVENT ==="); - process.stdout.write(inspect(event, false, null, true)); - process.stdout.write("🔒 [SENTRY-BEFORE-SEND-HOOK]: === END OF SENTRY EVENT ==="); - - // if return null, the event won't be sent - // ref https://github.com/getsentry/sentry-javascript/issues/2039 - return null; - }, - dsn: sentryDsn, - integrations: [ - new Dedupe(), - new Offline(), - ], - initialScope: { - tags: { - "process": processName, - }, - }, - environment: isProduction ? "production" : "development", - }); -} diff --git a/src/common/system-ca.ts b/src/common/system-ca.ts deleted file mode 100644 index 4e57ca2d7b..0000000000 --- a/src/common/system-ca.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { isMac, isWindows } from "./vars"; -import wincaAPI from "win-ca/api"; -import https from "https"; -import { promiseExecFile } from "./utils/promise-exec"; - -// DST Root CA X3, which was expired on 9.30.2021 -export const DSTRootCAX3 = "-----BEGIN CERTIFICATE-----\nMIIDSjCCAjKgAwIBAgIQRK+wgNajJ7qJMDmGLvhAazANBgkqhkiG9w0BAQUFADA/\nMSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT\nDkRTVCBSb290IENBIFgzMB4XDTAwMDkzMDIxMTIxOVoXDTIxMDkzMDE0MDExNVow\nPzEkMCIGA1UEChMbRGlnaXRhbCBTaWduYXR1cmUgVHJ1c3QgQ28uMRcwFQYDVQQD\nEw5EU1QgUm9vdCBDQSBYMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB\nAN+v6ZdQCINXtMxiZfaQguzH0yxrMMpb7NnDfcdAwRgUi+DoM3ZJKuM/IUmTrE4O\nrz5Iy2Xu/NMhD2XSKtkyj4zl93ewEnu1lcCJo6m67XMuegwGMoOifooUMM0RoOEq\nOLl5CjH9UL2AZd+3UWODyOKIYepLYYHsUmu5ouJLGiifSKOeDNoJjj4XLh7dIN9b\nxiqKqy69cK3FCxolkHRyxXtqqzTWMIn/5WgTe1QLyNau7Fqckh49ZLOMxt+/yUFw\n7BZy1SbsOFU5Q9D8/RhcQPGX69Wam40dutolucbY38EVAjqr2m7xPi71XAicPNaD\naeQQmxkqtilX4+U9m5/wAl0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV\nHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFMSnsaR7LHH62+FLkHX/xBVghYkQMA0GCSqG\nSIb3DQEBBQUAA4IBAQCjGiybFwBcqR7uKGY3Or+Dxz9LwwmglSBd49lZRNI+DT69\nikugdB/OEIKcdBodfpga3csTS7MgROSR6cz8faXbauX+5v3gTt23ADq1cEmv8uXr\nAvHRAosZy5Q6XkjEGB5YGV8eAlrwDPGxrancWYaLbumR9YbK+rlmM6pZW87ipxZz\nR8srzJmwN0jP41ZL9c8PDHIyh8bwRLtTcm1D9SZImlJnt1ir/md2cXjbDaJWFBM5\nJDGFoqgCWjBH4d1QB7wCCZAA62RjYJsWvIjJEubSfZGL+T0yjWW06XyxV3bqxbYo\nOb8VZRzI9neWagqNdwvYkQsEjgfbKbYK7p2CNTUQ\n-----END CERTIFICATE-----\n"; - -export function isCertActive(cert: string) { - const isExpired = typeof cert !== "string" || cert.includes(DSTRootCAX3); - - return !isExpired; -} - -// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Cheatsheet#other_assertions -const certSplitPattern = /(?=-----BEGIN\sCERTIFICATE-----)/g; - -async function execSecurity(...args: string[]): Promise { - const { stdout } = await promiseExecFile("/usr/bin/security", args); - - return stdout.split(certSplitPattern); -} - -/** - * Get root CA certificate from MacOSX system keychain - * Only return non-expred certificates. - */ -export async function getMacRootCA() { - // inspired mac-ca https://github.com/jfromaniello/mac-ca - const [trusted, rootCA] = await Promise.all([ - execSecurity("find-certificate", "-a", "-p"), - execSecurity("find-certificate", "-a", "-p", "/System/Library/Keychains/SystemRootCertificates.keychain"), - ]); - - return [...new Set([...trusted, ...rootCA])].filter(isCertActive); -} - -/** - * Get root CA certificate from Windows system certificate store. - * Only return non-expred certificates. - */ -export function getWinRootCA(): Promise { - return new Promise((resolve) => { - const CAs: string[] = []; - - wincaAPI({ - format: wincaAPI.der2.pem, - inject: false, - ondata: (ca: string) => { - CAs.push(ca); - }, - onend: () => { - resolve(CAs.filter(isCertActive)); - }, - }); - }); -} - - -/** - * Add (or merge) CAs to https.globalAgent.options.ca - */ -export function injectCAs(CAs: string[]) { - for (const cert of CAs) { - if (Array.isArray(https.globalAgent.options.ca) && !https.globalAgent.options.ca.includes(cert)) { - https.globalAgent.options.ca.push(cert); - } else { - https.globalAgent.options.ca = [cert]; - } - } -} - -/** - * Inject CAs found in OS's (Windoes/MacOSX only) root certificate store to https.globalAgent.options.ca - */ -export async function injectSystemCAs() { - if (isMac) { - try { - const osxRootCAs = await getMacRootCA(); - - injectCAs(osxRootCAs); - } catch (error) { - console.warn(`[MAC-CA]: Error injecting root CAs from MacOSX. ${error}`); - } - } - - if (isWindows) { - try { - const winRootCAs = await getWinRootCA(); - - wincaAPI.inject("+", winRootCAs); - - } catch (error) { - console.warn(`[WIN-CA]: Error injecting root CAs from Windows. ${error}`); - } - } -} diff --git a/src/common/test-utils/get-absolute-path-fake.ts b/src/common/test-utils/get-absolute-path-fake.ts deleted file mode 100644 index 89b5faa446..0000000000 --- a/src/common/test-utils/get-absolute-path-fake.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { GetAbsolutePath } from "../path/get-absolute-path.injectable"; - -export const getAbsolutePathFake: GetAbsolutePath = (...args) => { - const maybeAbsolutePath = args.join("/"); - - if (isAbsolutePath(maybeAbsolutePath)) { - return maybeAbsolutePath; - } - - return `/some-absolute-root-directory/${maybeAbsolutePath}`; -}; - -const isAbsolutePath = (path: string) => path.startsWith("/"); diff --git a/src/common/test-utils/get-global-override-for-function.ts b/src/common/test-utils/get-global-override-for-function.ts new file mode 100644 index 0000000000..238ee5621a --- /dev/null +++ b/src/common/test-utils/get-global-override-for-function.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Injectable } from "@ogre-tools/injectable"; +import { getGlobalOverride } from "./get-global-override"; +import { camelCase } from "lodash/fp"; + +export const getGlobalOverrideForFunction = ( + injectable: Injectable, +) => + getGlobalOverride(injectable, () => (...args: any[]) => { + console.warn( + `Tried to invoke a function "${injectable.id}" without override. The args were:`, + args, + ); + + throw new Error( + `Tried to invoke a function "${ + injectable.id + }" without override. Add eg. "di.override(${camelCase( + injectable.id, + )}Mock)" to the unit test interested in this.`, + ); + }); diff --git a/src/common/test-utils/join-paths-fake.ts b/src/common/test-utils/join-paths-fake.ts deleted file mode 100644 index 2796423d3c..0000000000 --- a/src/common/test-utils/join-paths-fake.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { JoinPaths } from "../path/join-paths.injectable"; - -export const joinPathsFake: JoinPaths = (...args) => args.join("/"); diff --git a/src/common/test-utils/use-fake-time.ts b/src/common/test-utils/use-fake-time.ts index 77cd385b4b..2a0956cebb 100644 --- a/src/common/test-utils/use-fake-time.ts +++ b/src/common/test-utils/use-fake-time.ts @@ -16,7 +16,7 @@ export const advanceFakeTime = (milliseconds: number) => { }); }; -export const useFakeTime = (dateTime: string) => { +export const useFakeTime = (dateTime = "2015-10-21T07:28:00Z") => { usingFakeTime = true; jest.useFakeTimers(); diff --git a/src/common/user-store/file-name-migration.global-override-for-injectable.ts b/src/common/user-store/file-name-migration.global-override-for-injectable.ts new file mode 100644 index 0000000000..bb0ac054f3 --- /dev/null +++ b/src/common/user-store/file-name-migration.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import userStoreFileNameMigrationInjectable from "./file-name-migration.injectable"; + +export default getGlobalOverride(userStoreFileNameMigrationInjectable, () => async () => {}); diff --git a/src/common/user-store/file-name-migration.injectable.ts b/src/common/user-store/file-name-migration.injectable.ts index 8f2dcf1e23..106f559ef0 100644 --- a/src/common/user-store/file-name-migration.injectable.ts +++ b/src/common/user-store/file-name-migration.injectable.ts @@ -4,32 +4,38 @@ */ import fse from "fs-extra"; -import path from "path"; import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; import { isErrnoException } from "../utils"; import { getInjectable } from "@ogre-tools/injectable"; +import joinPathsInjectable from "../path/join-paths.injectable"; + +export type UserStoreFileNameMigration = () => Promise; const userStoreFileNameMigrationInjectable = getInjectable({ id: "user-store-file-name-migration", - instantiate: (di) => { + instantiate: (di): UserStoreFileNameMigration => { const userDataPath = di.inject(directoryForUserDataInjectable); - const configJsonPath = path.join(userDataPath, "config.json"); - const lensUserStoreJsonPath = path.join(userDataPath, "lens-user-store.json"); + const joinPaths = di.inject(joinPathsInjectable); + const configJsonPath = joinPaths(userDataPath, "config.json"); + const lensUserStoreJsonPath = joinPaths(userDataPath, "lens-user-store.json"); - try { - fse.moveSync(configJsonPath, lensUserStoreJsonPath); - } catch (error) { - if (error instanceof Error && error.message === "dest already exists.") { - fse.removeSync(configJsonPath); - } else if (isErrnoException(error) && error.code === "ENOENT" && error.path === configJsonPath) { - // (No such file or directory) - return; // file already moved - } else { - // pass other errors along - throw error; + return async () => { + try { + await fse.move(configJsonPath, lensUserStoreJsonPath); + } catch (error) { + if (error instanceof Error && error.message === "dest already exists.") { + await fse.remove(configJsonPath); + } else if (isErrnoException(error) && error.code === "ENOENT" && error.path === configJsonPath) { + // (No such file or directory) + return; // file already moved + } else { + // pass other errors along + throw error; + } } - } + }; }, + causesSideEffects: true, }); export default userStoreFileNameMigrationInjectable; diff --git a/src/common/user-store/kubeconfig-syncs.injectable.ts b/src/common/user-store/kubeconfig-syncs.injectable.ts new file mode 100644 index 0000000000..bbe02fffad --- /dev/null +++ b/src/common/user-store/kubeconfig-syncs.injectable.ts @@ -0,0 +1,17 @@ +/** + * 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 userStoreInjectable from "./user-store.injectable"; + +const kubeconfigSyncsInjectable = getInjectable({ + id: "kubeconfig-syncs", + instantiate: (di) => { + const store = di.inject(userStoreInjectable); + + return store.syncKubeconfigEntries; + }, +}); + +export default kubeconfigSyncsInjectable; diff --git a/src/common/user-store/shell-setting.injectable.ts b/src/common/user-store/shell-setting.injectable.ts new file mode 100644 index 0000000000..f93a4e5874 --- /dev/null +++ b/src/common/user-store/shell-setting.injectable.ts @@ -0,0 +1,20 @@ +/** + * 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 { computed } from "mobx"; +import userInfoInjectable from "./user-info.injectable"; +import userStoreInjectable from "./user-store.injectable"; + +const userShellSettingInjectable = getInjectable({ + id: "user-shell-setting", + instantiate: (di) => { + const userStore = di.inject(userStoreInjectable); + const userInfo = di.inject(userInfoInjectable); + + return computed(() => userStore.shell || userInfo.shell); + }, +}); + +export default userShellSettingInjectable; diff --git a/src/common/user-store/user-info.global-override-for-injectable.ts b/src/common/user-store/user-info.global-override-for-injectable.ts new file mode 100644 index 0000000000..21fb26f8a9 --- /dev/null +++ b/src/common/user-store/user-info.global-override-for-injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import userInfoInjectable from "./user-info.injectable"; + +export default getGlobalOverride(userInfoInjectable, () => ({ + gid: 1, + homedir: "/some-home-dir", + shell: "bash", + uid: 2, + username: "some-user-name", +})); diff --git a/src/common/user-store/user-info.injectable.ts b/src/common/user-store/user-info.injectable.ts new file mode 100644 index 0000000000..b096da03c5 --- /dev/null +++ b/src/common/user-store/user-info.injectable.ts @@ -0,0 +1,14 @@ +/** + * 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 { userInfo } from "os"; + +const userInfoInjectable = getInjectable({ + id: "user-info", + instantiate: () => userInfo(), + causesSideEffects: true, +}); + +export default userInfoInjectable; diff --git a/src/common/user-store/user-store.injectable.ts b/src/common/user-store/user-store.injectable.ts index 3b4aba0b56..4e01cc50eb 100644 --- a/src/common/user-store/user-store.injectable.ts +++ b/src/common/user-store/user-store.injectable.ts @@ -3,10 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { ipcMain } from "electron"; -import userStoreFileNameMigrationInjectable from "./file-name-migration.injectable"; import { UserStore } from "./user-store"; -import selectedUpdateChannelInjectable from "../application-update/selected-update-channel/selected-update-channel.injectable"; +import selectedUpdateChannelInjectable from "../../features/application-update/common/selected-update-channel/selected-update-channel.injectable"; +import emitAppEventInjectable from "../app-event-bus/emit-event.injectable"; const userStoreInjectable = getInjectable({ id: "user-store", @@ -14,12 +13,9 @@ const userStoreInjectable = getInjectable({ instantiate: (di) => { UserStore.resetInstance(); - if (ipcMain) { - di.inject(userStoreFileNameMigrationInjectable); - } - return UserStore.createInstance({ selectedUpdateChannel: di.inject(selectedUpdateChannelInjectable), + emitAppEvent: di.inject(emitAppEventInjectable), }); }, diff --git a/src/common/user-store/user-store.ts b/src/common/user-store/user-store.ts index b806732735..7db6127ed8 100644 --- a/src/common/user-store/user-store.ts +++ b/src/common/user-store/user-store.ts @@ -4,19 +4,18 @@ */ import { app } from "electron"; -import semver from "semver"; -import { action, computed, observable, reaction, makeObservable, isObservableArray, isObservableSet, isObservableMap } from "mobx"; +import { action, observable, reaction, makeObservable, isObservableArray, isObservableSet, isObservableMap } from "mobx"; import { BaseStore } from "../base-store"; import migrations from "../../migrations/user-store"; -import { getAppVersion } from "../utils/app-version"; -import { kubeConfigDefaultPath } from "../kube-helpers"; -import { appEventBus } from "../app-event-bus/event-bus"; import { getOrInsertSet, toggle, toJS, object } from "../../renderer/utils"; import { DESCRIPTORS } from "./preferences-helpers"; import type { UserPreferencesModel, StoreType } from "./preferences-helpers"; import logger from "../../main/logger"; -import type { SelectedUpdateChannel } from "../application-update/selected-update-channel/selected-update-channel.injectable"; -import type { UpdateChannelId } from "../application-update/update-channels"; +import type { EmitAppEvent } from "../app-event-bus/emit-event.injectable"; + +// TODO: Remove coupling with Feature +import type { SelectedUpdateChannel } from "../../features/application-update/common/selected-update-channel/selected-update-channel.injectable"; +import type { ReleaseChannel } from "../../features/application-update/common/update-channels"; export interface UserStoreModel { lastSeenAppVersion: string; @@ -24,7 +23,8 @@ export interface UserStoreModel { } interface Dependencies { - selectedUpdateChannel: SelectedUpdateChannel; + readonly selectedUpdateChannel: SelectedUpdateChannel; + emitAppEvent: EmitAppEvent; } export class UserStore extends BaseStore /* implements UserStoreFlatModel (when strict null is enabled) */ { @@ -37,17 +37,10 @@ export class UserStore extends BaseStore /* implements UserStore }); makeObservable(this); - this.load(); } @observable lastSeenAppVersion = "0.0.0"; - /** - * used in add-cluster page for providing context - * @deprecated No longer used - */ - @observable kubeConfigPath = kubeConfigDefaultPath; - /** * @deprecated No longer used */ @@ -98,14 +91,6 @@ export class UserStore extends BaseStore /* implements UserStore */ @observable syncKubeconfigEntries!: StoreType; - @computed get isNewVersion() { - return semver.gt(getAppVersion(), this.lastSeenAppVersion); - } - - @computed get resolvedShell(): string | undefined { - return this.shell || process.env.SHELL || process.env.PTYSHELL; - } - startMainReactions() { // open at system start-up reaction(() => this.openAtLogin, openAtLogin => { @@ -151,12 +136,6 @@ export class UserStore extends BaseStore /* implements UserStore this.colorTheme = DESCRIPTORS.colorTheme.fromStore(undefined); } - @action - saveLastSeenAppVersion() { - appEventBus.emit({ name: "app", action: "whats-new-seen" }); - this.lastSeenAppVersion = getAppVersion(); - } - @action protected fromStore({ lastSeenAppVersion, preferences }: Partial = {}) { logger.debug("UserStore.fromStore()", { lastSeenAppVersion, preferences }); @@ -180,7 +159,7 @@ export class UserStore extends BaseStore /* implements UserStore // TODO: Switch to action-based saving instead saving stores by reaction if (preferences?.updateChannel) { - this.dependencies.selectedUpdateChannel.setValue(preferences?.updateChannel as UpdateChannelId); + this.dependencies.selectedUpdateChannel.setValue(preferences?.updateChannel as ReleaseChannel); } } diff --git a/src/common/utils/__tests__/paths.test.ts b/src/common/utils/__tests__/paths.test.ts index f5e8a7ccf5..5a52843432 100644 --- a/src/common/utils/__tests__/paths.test.ts +++ b/src/common/utils/__tests__/paths.test.ts @@ -3,12 +3,29 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { describeIf } from "../../../../integration/helpers/utils"; -import { isWindows } from "../../vars"; -import { isLogicalChildPath } from "../paths"; +import type { DiContainer } from "@ogre-tools/injectable"; +import path from "path"; +import { getDiForUnitTesting } from "../../../main/getDiForUnitTesting"; +import getAbsolutePathInjectable from "../../path/get-absolute-path.injectable"; +import getDirnameOfPathInjectable from "../../path/get-dirname.injectable"; +import type { IsLogicalChildPath } from "../../path/is-logical-child-path.injectable"; +import isLogicalChildPathInjectable from "../../path/is-logical-child-path.injectable"; describe("isLogicalChildPath", () => { - describeIf(isWindows)("windows tests", () => { + let di: DiContainer; + let isLogicalChildPath: IsLogicalChildPath; + + beforeEach(() => { + di = getDiForUnitTesting(); + }); + + describe("when using win32 paths", () => { + beforeEach(() => { + di.override(getAbsolutePathInjectable, () => path.win32.resolve); + di.override(getDirnameOfPathInjectable, () => path.win32.dirname); + isLogicalChildPath = di.inject(isLogicalChildPathInjectable); + }); + it.each([ { parentPath: "C:\\Foo", @@ -40,7 +57,13 @@ describe("isLogicalChildPath", () => { }); }); - describeIf(!isWindows)("posix tests", () => { + describe("when using posix paths", () => { + beforeEach(() => { + di.override(getAbsolutePathInjectable, () => path.posix.resolve); + di.override(getDirnameOfPathInjectable, () => path.posix.dirname); + isLogicalChildPath = di.inject(isLogicalChildPathInjectable); + }); + it.each([ { parentPath: "/foo", diff --git a/src/common/utils/abort-controller.ts b/src/common/utils/abort-controller.ts index 8172e6a81a..b062fce487 100644 --- a/src/common/utils/abort-controller.ts +++ b/src/common/utils/abort-controller.ts @@ -18,3 +18,9 @@ export class WrappedAbortController extends AbortController { }); } } + +export function setTimeoutFor(controller: AbortController, timeout: number): void { + const handle = setTimeout(() => controller.abort(), timeout); + + controller.signal.addEventListener("abort", () => clearTimeout(handle)); +} diff --git a/src/common/utils/add-separator/add-separator.test.ts b/src/common/utils/add-separator/add-separator.test.ts new file mode 100644 index 0000000000..8a45b45739 --- /dev/null +++ b/src/common/utils/add-separator/add-separator.test.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { addSeparator } from "./add-separator"; + +describe("add-separator", () => { + it("given multiple items, adds separators", () => { + const items = ["first", "second", "third"]; + + const actual = addSeparator((left, right) => `separator-between-${left}-and-${right}`, items); + + expect(actual).toEqual([ + "first", + "separator-between-first-and-second", + "second", + "separator-between-second-and-third", + "third", + ]); + }); + + it("given multiple items including falsy ones, adds separators", () => { + const items = [false, undefined, null, NaN]; + + const actual = addSeparator((left, right) => `separator-between-${left}-and-${right}`, items); + + expect(actual).toEqual([ + false, + "separator-between-false-and-undefined", + undefined, + "separator-between-undefined-and-null", + null, + "separator-between-null-and-NaN", + NaN, + ]); + }); + + it("given no items, does not add separator", () => { + const items: any[] = []; + + const actual = addSeparator(() => "separator", items); + + expect(actual).toEqual([]); + }); + + it("given one item, does not add separator", () => { + const items = ["first"]; + + const actual = addSeparator(() => "separator", items); + + expect(actual).toEqual(["first"]); + }); +}); diff --git a/src/common/utils/add-separator/add-separator.ts b/src/common/utils/add-separator/add-separator.ts new file mode 100644 index 0000000000..6fd6b14642 --- /dev/null +++ b/src/common/utils/add-separator/add-separator.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export type GetSeparator = (left: Item, right: Item) => Separator; + +export const addSeparator = ( + getSeparator: GetSeparator, + items: Item[], +) => items.flatMap(toSeparatedTupleUsing(getSeparator)); + +const toSeparatedTupleUsing = + (getSeparator: GetSeparator) => + (leftItem: Item, index: number, arr: Item[]) => { + const itemIsLast = arr.length === index + 1; + + if (itemIsLast) { + return [leftItem]; + } + + const rightItem = arr[index + 1]; + const separator = getSeparator(leftItem, rightItem); + + return [leftItem, separator]; + }; diff --git a/src/common/utils/app-version.ts b/src/common/utils/app-version.ts index 28882e092b..183cc3e6b2 100644 --- a/src/common/utils/app-version.ts +++ b/src/common/utils/app-version.ts @@ -4,15 +4,6 @@ */ import requestPromise from "request-promise-native"; -import packageInfo from "../../../package.json"; - -export function getAppVersion(): string { - return packageInfo.version; -} - -export function getBundledKubectlVersion(): string { - return packageInfo.config.bundledKubectlVersion; -} export async function getAppVersionFromProxyServer(proxyPort: number): Promise { const response = await requestPromise({ diff --git a/src/common/utils/async-result.ts b/src/common/utils/async-result.ts index 4e4f37866e..69927f3275 100644 --- a/src/common/utils/async-result.ts +++ b/src/common/utils/async-result.ts @@ -3,5 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ export type AsyncResult = - | { callWasSuccessful: true; response: Response } + | ( + Response extends void + ? { callWasSuccessful: true; response?: undefined } + : { callWasSuccessful: true; response: Response } + ) | { callWasSuccessful: false; error: Error }; diff --git a/src/common/utils/binary-name.injectable.ts b/src/common/utils/binary-name.injectable.ts new file mode 100644 index 0000000000..d240b3aae0 --- /dev/null +++ b/src/common/utils/binary-name.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import normalizedPlatformInjectable from "../vars/normalized-platform.injectable"; + +const binaryNameInjectable = getInjectable({ + id: "binary-name", + instantiate: (di, binaryName) => { + const normalizedPlatform = di.inject(normalizedPlatformInjectable); + + if (normalizedPlatform === "windows") { + return `${binaryName}.exe`; + } + + return binaryName; + }, + lifecycle: lifecycleEnum.keyedSingleton({ + getInstanceKey: (di, binaryName: string) => binaryName, + }), +}); + +export default binaryNameInjectable; diff --git a/src/common/utils/buildUrl.ts b/src/common/utils/buildUrl.ts index 61639cea4d..82d114cd08 100644 --- a/src/common/utils/buildUrl.ts +++ b/src/common/utils/buildUrl.ts @@ -35,3 +35,24 @@ export function buildURLPositional

return buildURL(path, { params, query, fragment }); }; } + +export type UrlParamsFor = + Pathname extends `${string}/:${infer A}?/${infer Tail}` + ? Partial> & UrlParamsFor<`/${Tail}`> + : Pathname extends `${string}/:${infer A}/${infer Tail}` + ? Record & UrlParamsFor<`/${Tail}`> + : Pathname extends `${string}/:${infer A}?` + ? Partial> + : Pathname extends `${string}/:${infer A}` + ? Record + : {}; + +export interface UrlBuilder { + compile(params: UrlParamsFor, query?: object, fragment?: string): string; +} + +export function urlBuilderFor(pathname: Pathname): UrlBuilder { + return { + compile: buildURLPositional(pathname), + }; +} diff --git a/src/common/utils/bundled-binary-path.injectable.ts b/src/common/utils/bundled-binary-path.injectable.ts new file mode 100644 index 0000000000..42ecbca0d9 --- /dev/null +++ b/src/common/utils/bundled-binary-path.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import joinPathsInjectable from "../path/join-paths.injectable"; +import baseBundledBinariesDirectoryInjectable from "../vars/base-bundled-binaries-dir.injectable"; +import binaryNameInjectable from "./binary-name.injectable"; + +const bundledBinaryPathInjectable = getInjectable({ + id: "bundled-binary-path", + instantiate: (di, name) => { + const joinPaths = di.inject(joinPathsInjectable); + const binaryName = di.inject(binaryNameInjectable, name); + const baseBundledBinariesDirectory = di.inject(baseBundledBinariesDirectoryInjectable); + + return joinPaths(baseBundledBinariesDirectory, binaryName); + }, + lifecycle: lifecycleEnum.keyedSingleton({ + getInstanceKey: (di, binaryName: string) => binaryName, + }), +}); + +export default bundledBinaryPathInjectable; diff --git a/src/common/utils/camelCase.ts b/src/common/utils/camelCase.ts index 3ef727a2e5..a37e4c7f5c 100644 --- a/src/common/utils/camelCase.ts +++ b/src/common/utils/camelCase.ts @@ -6,7 +6,8 @@ // Convert object's keys to camelCase format import { camelCase } from "lodash"; import type { SingleOrMany } from "./types"; -import { isObject } from "./type-narrowing"; +import { isObject, isString } from "./type-narrowing"; +import * as object from "./objects"; export function toCamelCase[]>(obj: T): T; export function toCamelCase>(obj: T): T; @@ -17,11 +18,10 @@ export function toCamelCase(obj: SingleOrMany | unknown> } if (isObject(obj)) { - return Object.fromEntries( - Object.entries(obj) - .map(([key, value]) => { - return [camelCase(key), toCamelCase(value)]; - }), + return object.fromEntries( + object.entries(obj) + .filter((pair): pair is [string, unknown] => isString(pair[0])) + .map(([key, value]) => [camelCase(key), isObject(value) ? toCamelCase(value) : value]), ); } diff --git a/src/common/utils/channel/channel.test.ts b/src/common/utils/channel/channel.test.ts index c5f52c0874..9a361b6770 100644 --- a/src/common/utils/channel/channel.test.ts +++ b/src/common/utils/channel/channel.test.ts @@ -4,31 +4,31 @@ */ import type { DiContainer } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable"; -import type { MessageToChannel } from "./message-to-channel-injection-token"; -import { messageToChannelInjectionToken } from "./message-to-channel-injection-token"; +import type { SendMessageToChannel } from "./message-to-channel-injection-token"; +import { sendMessageToChannelInjectionToken } from "./message-to-channel-injection-token"; import type { ApplicationBuilder } from "../../../renderer/components/test-utils/get-application-builder"; import { getApplicationBuilder } from "../../../renderer/components/test-utils/get-application-builder"; import type { LensWindow } from "../../../main/start-main-application/lens-window/application-window/create-lens-window.injectable"; +import type { MessageChannel } from "./message-channel-listener-injection-token"; import { messageChannelListenerInjectionToken } from "./message-channel-listener-injection-token"; -import type { MessageChannel } from "./message-channel-injection-token"; import type { RequestFromChannel } from "./request-from-channel-injection-token"; import { requestFromChannelInjectionToken } from "./request-from-channel-injection-token"; -import type { RequestChannel } from "./request-channel-injection-token"; -import { requestChannelListenerInjectionToken } from "./request-channel-listener-injection-token"; +import type { RequestChannel } from "./request-channel-listener-injection-token"; import type { AsyncFnMock } from "@async-fn/jest"; import asyncFn from "@async-fn/jest"; import { getPromiseStatus } from "../../test-utils/get-promise-status"; import { runInAction } from "mobx"; +import type { RequestChannelHandler } from "../../../main/utils/channel/channel-listeners/listener-tokens"; +import { getRequestChannelListenerInjectable } from "../../../main/utils/channel/channel-listeners/listener-tokens"; type TestMessageChannel = MessageChannel; type TestRequestChannel = RequestChannel; describe("channel", () => { describe("messaging from main to renderer, given listener for channel in a window and application has started", () => { - let testMessageChannel: TestMessageChannel; let messageListenerInWindowMock: jest.Mock; let mainDi: DiContainer; - let messageToChannel: MessageToChannel; + let messageToChannel: SendMessageToChannel; let builder: ApplicationBuilder; beforeEach(async () => { @@ -39,24 +39,17 @@ describe("channel", () => { const testChannelListenerInTestWindowInjectable = getInjectable({ id: "test-channel-listener-in-test-window", - instantiate: (di) => ({ - channel: di.inject(testMessageChannelInjectable), + instantiate: () => ({ + channel: testMessageChannel, handler: messageListenerInWindowMock, }), injectionToken: messageChannelListenerInjectionToken, }); - builder.beforeApplicationStart((mainDi) => { - runInAction(() => { - mainDi.register(testMessageChannelInjectable); - }); - }); - builder.beforeWindowStart((windowDi) => { runInAction(() => { windowDi.register(testChannelListenerInTestWindowInjectable); - windowDi.register(testMessageChannelInjectable); }); }); @@ -64,8 +57,7 @@ describe("channel", () => { await builder.startHidden(); - testMessageChannel = mainDi.inject(testMessageChannelInjectable); - messageToChannel = mainDi.inject(messageToChannelInjectionToken); + messageToChannel = mainDi.inject(sendMessageToChannelInjectionToken); }); describe("given window is started", () => { @@ -109,9 +101,8 @@ describe("channel", () => { }); describe("messaging from renderer to main, given listener for channel in a main and application has started", () => { - let testMessageChannel: TestMessageChannel; let messageListenerInMainMock: jest.Mock; - let messageToChannel: MessageToChannel; + let messageToChannel: SendMessageToChannel; beforeEach(async () => { const applicationBuilder = getApplicationBuilder(); @@ -121,9 +112,8 @@ describe("channel", () => { const testChannelListenerInMainInjectable = getInjectable({ id: "test-channel-listener-in-main", - instantiate: (di) => ({ - channel: di.inject(testMessageChannelInjectable), - + instantiate: () => ({ + channel: testMessageChannel, handler: messageListenerInMainMock, }), @@ -133,13 +123,6 @@ describe("channel", () => { applicationBuilder.beforeApplicationStart((mainDi) => { runInAction(() => { mainDi.register(testChannelListenerInMainInjectable); - mainDi.register(testMessageChannelInjectable); - }); - }); - - applicationBuilder.beforeWindowStart((windowDi) => { - runInAction(() => { - windowDi.register(testMessageChannelInjectable); }); }); @@ -147,8 +130,7 @@ describe("channel", () => { const windowDi = applicationBuilder.applicationWindow.only.di; - testMessageChannel = windowDi.inject(testMessageChannelInjectable); - messageToChannel = windowDi.inject(messageToChannelInjectionToken); + messageToChannel = windowDi.inject(sendMessageToChannelInjectionToken); }); it("when sending message, triggers listener in main", () => { @@ -159,8 +141,7 @@ describe("channel", () => { }); describe("requesting from main in renderer, given listener for channel in a main and application has started", () => { - let testRequestChannel: TestRequestChannel; - let requestListenerInMainMock: AsyncFnMock<(arg: string) => string>; + let requestListenerInMainMock: AsyncFnMock>; let requestFromChannel: RequestFromChannel; beforeEach(async () => { @@ -168,28 +149,14 @@ describe("channel", () => { requestListenerInMainMock = asyncFn(); - const testChannelListenerInMainInjectable = getInjectable({ - id: "test-channel-listener-in-main", - - instantiate: (di) => ({ - channel: di.inject(testRequestChannelInjectable), - - handler: requestListenerInMainMock, - }), - - injectionToken: requestChannelListenerInjectionToken, + const testChannelListenerInMainInjectable = getRequestChannelListenerInjectable({ + channel: testRequestChannel, + handler: () => requestListenerInMainMock, }); applicationBuilder.beforeApplicationStart((mainDi) => { runInAction(() => { mainDi.register(testChannelListenerInMainInjectable); - mainDi.register(testRequestChannelInjectable); - }); - }); - - applicationBuilder.beforeWindowStart((windowDi) => { - runInAction(() => { - windowDi.register(testRequestChannelInjectable); }); }); @@ -197,8 +164,6 @@ describe("channel", () => { const windowDi = applicationBuilder.applicationWindow.only.di; - testRequestChannel = windowDi.inject(testRequestChannelInjectable); - requestFromChannel = windowDi.inject( requestFromChannelInjectionToken, ); @@ -230,21 +195,37 @@ describe("channel", () => { }); }); }); + + it("when registering multiple handlers for the same channel, throws", async () => { + const applicationBuilder = getApplicationBuilder(); + + const testChannelListenerInMainInjectable = getRequestChannelListenerInjectable({ + channel: testRequestChannel, + handler: () => () => "some-value", + }); + const testChannelListenerInMain2Injectable = getRequestChannelListenerInjectable({ + channel: testRequestChannel, + handler: () => () => "some-other-value", + }); + + testChannelListenerInMain2Injectable.id += "2"; + + applicationBuilder.beforeApplicationStart((mainDi) => { + runInAction(() => { + mainDi.register(testChannelListenerInMainInjectable); + mainDi.register(testChannelListenerInMain2Injectable); + }); + }); + + await expect(applicationBuilder.render()).rejects.toThrow('Tried to register a multiple channel handlers for "some-request-channel-id", only one handler is supported for a request channel.'); + }); }); -const testMessageChannelInjectable = getInjectable({ - id: "some-message-test-channel", +const testMessageChannel: TestMessageChannel = { + id: "some-message-channel-id", +}; - instantiate: (): TestMessageChannel => ({ - id: "some-message-channel-id", - }), -}); - -const testRequestChannelInjectable = getInjectable({ - id: "some-request-test-channel", - - instantiate: (): TestRequestChannel => ({ - id: "some-request-channel-id", - }), -}); +const testRequestChannel: TestRequestChannel = { + id: "some-request-channel-id", +}; diff --git a/src/common/utils/channel/enlist-message-channel-listener-injection-token.ts b/src/common/utils/channel/enlist-message-channel-listener-injection-token.ts index fa6983e130..34f62d51d5 100644 --- a/src/common/utils/channel/enlist-message-channel-listener-injection-token.ts +++ b/src/common/utils/channel/enlist-message-channel-listener-injection-token.ts @@ -3,14 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectionToken } from "@ogre-tools/injectable"; -import type { MessageChannel } from "./message-channel-injection-token"; -import type { MessageChannelListener } from "./message-channel-listener-injection-token"; +import type { Disposer } from "../disposer"; +import type { MessageChannel, MessageChannelListener } from "./message-channel-listener-injection-token"; -export type EnlistMessageChannelListener = < - TChannel extends MessageChannel, ->(listener: MessageChannelListener) => () => void; +export type EnlistMessageChannelListener = (listener: MessageChannelListener>) => Disposer; -export const enlistMessageChannelListenerInjectionToken = - getInjectionToken({ - id: "enlist-message-channel-listener", - }); +export const enlistMessageChannelListenerInjectionToken = getInjectionToken({ + id: "enlist-message-channel-listener", +}); diff --git a/src/common/utils/channel/enlist-request-channel-listener-injection-token.ts b/src/common/utils/channel/enlist-request-channel-listener-injection-token.ts deleted file mode 100644 index f87082c466..0000000000 --- a/src/common/utils/channel/enlist-request-channel-listener-injection-token.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectionToken } from "@ogre-tools/injectable"; -import type { RequestChannel } from "./request-channel-injection-token"; -import type { RequestChannelListener } from "./request-channel-listener-injection-token"; - -export type EnlistRequestChannelListener = < - TChannel extends RequestChannel, ->(listener: RequestChannelListener) => () => void; - -export const enlistRequestChannelListenerInjectionToken = - getInjectionToken({ - id: "enlist-request-channel-listener", - }); diff --git a/src/common/utils/channel/listening-of-channels.injectable.ts b/src/common/utils/channel/listening-of-channels.injectable.ts deleted file mode 100644 index 30fee42fb9..0000000000 --- a/src/common/utils/channel/listening-of-channels.injectable.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * 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 { getStartableStoppable } from "../get-startable-stoppable"; -import { disposer } from "../index"; -import { messageChannelListenerInjectionToken } from "./message-channel-listener-injection-token"; -import { requestChannelListenerInjectionToken } from "./request-channel-listener-injection-token"; -import { enlistMessageChannelListenerInjectionToken } from "./enlist-message-channel-listener-injection-token"; -import { enlistRequestChannelListenerInjectionToken } from "./enlist-request-channel-listener-injection-token"; - -const listeningOfChannelsInjectable = getInjectable({ - id: "listening-of-channels", - - instantiate: (di) => { - const enlistMessageChannelListener = di.inject(enlistMessageChannelListenerInjectionToken); - const enlistRequestChannelListener = di.inject(enlistRequestChannelListenerInjectionToken); - const messageChannelListeners = di.injectMany(messageChannelListenerInjectionToken); - const requestChannelListeners = di.injectMany(requestChannelListenerInjectionToken); - - return getStartableStoppable("listening-of-channels", () => { - const messageChannelDisposers = messageChannelListeners.map(enlistMessageChannelListener); - const requestChannelDisposers = requestChannelListeners.map(enlistRequestChannelListener); - - return disposer(...messageChannelDisposers, ...requestChannelDisposers); - }); - }, -}); - - -export default listeningOfChannelsInjectable; diff --git a/src/common/utils/channel/listening-on-message-channels.injectable.ts b/src/common/utils/channel/listening-on-message-channels.injectable.ts new file mode 100644 index 0000000000..afe0c08f24 --- /dev/null +++ b/src/common/utils/channel/listening-on-message-channels.injectable.ts @@ -0,0 +1,25 @@ +/** + * 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 { getStartableStoppable } from "../get-startable-stoppable"; +import { disposer } from "../index"; +import { messageChannelListenerInjectionToken } from "./message-channel-listener-injection-token"; +import { enlistMessageChannelListenerInjectionToken } from "./enlist-message-channel-listener-injection-token"; + +const listeningOnMessageChannelsInjectable = getInjectable({ + id: "listening-on-message-channels", + + instantiate: (di) => { + const enlistMessageChannelListener = di.inject(enlistMessageChannelListenerInjectionToken); + const messageChannelListeners = di.injectMany(messageChannelListenerInjectionToken); + + return getStartableStoppable("listening-on-channels", () => ( + disposer(messageChannelListeners.map(enlistMessageChannelListener)) + )); + }, +}); + + +export default listeningOnMessageChannelsInjectable; diff --git a/src/common/utils/channel/message-channel-injection-token.ts b/src/common/utils/channel/message-channel-injection-token.ts deleted file mode 100644 index 3141acedf3..0000000000 --- a/src/common/utils/channel/message-channel-injection-token.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { getInjectionToken } from "@ogre-tools/injectable"; -import type { JsonValue } from "type-fest"; - -export interface MessageChannel { - id: string; - _messageSignature?: Message; -} - -export const messageChannelInjectionToken = getInjectionToken>({ - id: "message-channel", -}); diff --git a/src/common/utils/channel/message-channel-listener-injection-token.ts b/src/common/utils/channel/message-channel-listener-injection-token.ts index 8879e19013..5bfc45a82d 100644 --- a/src/common/utils/channel/message-channel-listener-injection-token.ts +++ b/src/common/utils/channel/message-channel-listener-injection-token.ts @@ -2,17 +2,50 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectionToken } from "@ogre-tools/injectable"; -import type { SetRequired } from "type-fest"; -import type { MessageChannel } from "./message-channel-injection-token"; +import type { DiContainerForInjection } from "@ogre-tools/injectable"; +import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; -export interface MessageChannelListener> { - channel: TChannel; - handler: (value: SetRequired["_messageSignature"]) => void; +export interface MessageChannel { + id: string; + _messageSignature?: Message; // only used to mark `Message` as used } -export const messageChannelListenerInjectionToken = getInjectionToken>>( +export type MessageChannelHandler = Channel extends MessageChannel + ? (message: Message) => void + : never; + +export interface MessageChannelListener { + channel: Channel; + handler: MessageChannelHandler; +} + +export const messageChannelListenerInjectionToken = getInjectionToken>>( { id: "message-channel-listener", }, ); + +export interface GetMessageChannelListenerInfo< + Channel extends MessageChannel, + Message, +> { + id: string; + channel: Channel; + handler: (di: DiContainerForInjection) => MessageChannelHandler; + causesSideEffects?: boolean; +} + +export function getMessageChannelListenerInjectable< + Channel extends MessageChannel, + Message, +>(info: GetMessageChannelListenerInfo) { + return getInjectable({ + id: `${info.channel.id}-listener-${info.id}`, + instantiate: (di) => ({ + channel: info.channel, + handler: info.handler(di), + }), + injectionToken: messageChannelListenerInjectionToken, + causesSideEffects: info.causesSideEffects, + }); +} diff --git a/src/common/utils/channel/message-to-channel-injection-token.ts b/src/common/utils/channel/message-to-channel-injection-token.ts index 8c5f03b9ee..3ffd75f4f7 100644 --- a/src/common/utils/channel/message-to-channel-injection-token.ts +++ b/src/common/utils/channel/message-to-channel-injection-token.ts @@ -3,21 +3,19 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectionToken } from "@ogre-tools/injectable"; -import type { SetRequired } from "type-fest"; -import type { MessageChannel } from "./message-channel-injection-token"; +import type { MessageChannel } from "./message-channel-listener-injection-token"; -export interface MessageToChannel { - , TMessage extends void>( - channel: TChannel, - ): void; - - >( - channel: TChannel, - message: SetRequired["_messageSignature"], - ): void; +export interface SendMessageToChannel { + (channel: MessageChannel): void; + (channel: MessageChannel, message: Message): void; } -export const messageToChannelInjectionToken = - getInjectionToken({ - id: "message-to-message-channel", - }); +export type MessageChannelSender = Channel extends MessageChannel + ? () => void + : Channel extends MessageChannel + ? (message: Message) => void + : never; + +export const sendMessageToChannelInjectionToken = getInjectionToken({ + id: "send-message-to-message-channel", +}); diff --git a/src/common/utils/channel/request-channel-injection-token.ts b/src/common/utils/channel/request-channel-injection-token.ts deleted file mode 100644 index 67044db878..0000000000 --- a/src/common/utils/channel/request-channel-injection-token.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { getInjectionToken } from "@ogre-tools/injectable"; -import type { JsonValue } from "type-fest"; - -export interface RequestChannel< - Request extends JsonValue | void = void, - Response extends JsonValue | void = void, -> { - id: string; - _requestSignature?: Request; - _responseSignature?: Response; -} - -export const requestChannelInjectionToken = getInjectionToken>({ - id: "request-channel", -}); diff --git a/src/common/utils/channel/request-channel-listener-injection-token.ts b/src/common/utils/channel/request-channel-listener-injection-token.ts index 690b96d9dc..2f0b84a3cc 100644 --- a/src/common/utils/channel/request-channel-listener-injection-token.ts +++ b/src/common/utils/channel/request-channel-listener-injection-token.ts @@ -2,24 +2,9 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectionToken } from "@ogre-tools/injectable"; -import type { SetRequired } from "type-fest"; -import type { RequestChannel } from "./request-channel-injection-token"; -export interface RequestChannelListener> { - channel: TChannel; - - handler: ( - request: SetRequired["_requestSignature"] - ) => - | SetRequired["_responseSignature"] - | Promise< - SetRequired["_responseSignature"] - >; +export interface RequestChannel { + id: string; + _requestSignature?: Request; // used only to mark `Request` as "used" + _responseSignature?: Response; // used only to mark `Response` as "used" } - -export const requestChannelListenerInjectionToken = getInjectionToken>>( - { - id: "request-channel-listener", - }, -); diff --git a/src/common/utils/channel/request-from-channel-injection-token.ts b/src/common/utils/channel/request-from-channel-injection-token.ts index 5f4492543f..14e925f190 100644 --- a/src/common/utils/channel/request-from-channel-injection-token.ts +++ b/src/common/utils/channel/request-from-channel-injection-token.ts @@ -3,19 +3,13 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectionToken } from "@ogre-tools/injectable"; -import type { SetRequired } from "type-fest"; -import type { RequestChannel } from "./request-channel-injection-token"; +import type { RequestChannel } from "./request-channel-listener-injection-token"; -export type RequestFromChannel = < - TChannel extends RequestChannel, ->( - channel: TChannel, - ...request: TChannel["_requestSignature"] extends void - ? [] - : [TChannel["_requestSignature"]] -) => Promise["_responseSignature"]>; +export interface RequestFromChannel { + (channel: RequestChannel, request: Request): Promise; + (channel: RequestChannel): Promise; +} -export const requestFromChannelInjectionToken = - getInjectionToken({ - id: "request-from-request-channel", - }); +export const requestFromChannelInjectionToken = getInjectionToken({ + id: "request-from-request-channel", +}); diff --git a/src/common/utils/composable-responsibilities/discriminable/discriminable.ts b/src/common/utils/composable-responsibilities/discriminable/discriminable.ts new file mode 100644 index 0000000000..7976004ee6 --- /dev/null +++ b/src/common/utils/composable-responsibilities/discriminable/discriminable.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// See: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions +export interface Discriminable { readonly kind: T } + +// Note: this will fail at transpilation time, if all kinds are not instructed in switch/case. +// See: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#exhaustiveness-checking +export const checkThatAllDiscriminablesAreExhausted = (value: T) => { + const _exhaustiveCheck: never = value; + + return new Error( + `Tried to exhaust discriminables, but no instructions were found for ${(_exhaustiveCheck as any).kind}`, + ); +}; diff --git a/src/renderer/api/index.ts b/src/common/utils/composable-responsibilities/labelable/labelable.ts similarity index 71% rename from src/renderer/api/index.ts rename to src/common/utils/composable-responsibilities/labelable/labelable.ts index 379d1e292b..e0f2d98c21 100644 --- a/src/renderer/api/index.ts +++ b/src/common/utils/composable-responsibilities/labelable/labelable.ts @@ -2,5 +2,6 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ - -export { apiBase, apiKube } from "../../common/k8s-api"; +export interface Labelable { + readonly label: string; +} diff --git a/src/common/utils/composable-responsibilities/orderable/orderable.ts b/src/common/utils/composable-responsibilities/orderable/orderable.ts new file mode 100644 index 0000000000..e0425cc0a7 --- /dev/null +++ b/src/common/utils/composable-responsibilities/orderable/orderable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { sortBy } from "lodash/fp"; + +export interface Orderable { + readonly orderNumber: number; +} + +export type MaybeOrderable = Orderable | object; + +export const orderByOrderNumber = (maybeOrderables: T[]) => + sortBy( + (orderable) => + "orderNumber" in orderable + ? orderable.orderNumber + : Number.MAX_SAFE_INTEGER, + maybeOrderables, + ); diff --git a/src/common/utils/composable-responsibilities/showable/showable.ts b/src/common/utils/composable-responsibilities/showable/showable.ts new file mode 100644 index 0000000000..ad8e2ed25b --- /dev/null +++ b/src/common/utils/composable-responsibilities/showable/showable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { IComputedValue } from "mobx"; +import { isBoolean } from "../../type-narrowing"; + +export interface Showable { + readonly isShown: IComputedValue | boolean; +} + +export type MaybeShowable = Showable | object; + +export const isShown = (showable: MaybeShowable) => { + if (!("isShown" in showable)) { + return true; + } + + if (showable.isShown === undefined) { + return true; + } + + if (isBoolean(showable.isShown)) { + return showable.isShown; + } + + return showable.isShown.get(); +}; diff --git a/src/common/utils/composite/composite-has-descendant/composite-has-descendant.test.ts b/src/common/utils/composite/composite-has-descendant/composite-has-descendant.test.ts new file mode 100644 index 0000000000..0b7f07bf2d --- /dev/null +++ b/src/common/utils/composite/composite-has-descendant/composite-has-descendant.test.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { Composite } from "../get-composite/get-composite"; +import { compositeHasDescendant } from "./composite-has-descendant"; +import { getCompositeFor } from "../get-composite/get-composite"; + +describe("composite-has-descendant, given composite with children and grand children", () => { + let composite: Composite<{ id: string; parentId?: string }>; + + beforeEach(() => { + const items = [ + { id: "some-root-id", parentId: undefined }, + { id: "some-child-item", parentId: "some-root-id" }, + + { + id: "some-grand-child-item", + parentId: "some-child-item", + }, + ]; + + const getComposite = getCompositeFor<{ + id: string; + parentId?: string; + }>({ + rootId: "some-root-id", + getId: (x) => x.id, + getParentId: (x) => x.parentId, + }); + + composite = getComposite(items); + }); + + it("has a child as descendant", () => { + const actual = compositeHasDescendant( + (referenceComposite) => referenceComposite.value.id === "some-child-item", + )(composite); + + expect(actual).toBe(true); + }); + + it("has a grand child as descendant", () => { + const actual = compositeHasDescendant( + (referenceComposite) => + referenceComposite.value.id === "some-grand-child-item", + )(composite); + + expect(actual).toBe(true); + }); + + it("does not have an unrelated descendant", () => { + const actual = compositeHasDescendant( + (referenceComposite) => + referenceComposite.value.id === "some-unknown-item", + )(composite); + + expect(actual).toBe(false); + }); +}); diff --git a/src/common/utils/composite/composite-has-descendant/composite-has-descendant.ts b/src/common/utils/composite/composite-has-descendant/composite-has-descendant.ts new file mode 100644 index 0000000000..030f79ad74 --- /dev/null +++ b/src/common/utils/composite/composite-has-descendant/composite-has-descendant.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Composite } from "../get-composite/get-composite"; + +const compositeHasDescendant = ( + predicate: (referenceComposite: Composite) => boolean, +) => { + const _compositeHasDescendant = (composite: Composite): boolean => + predicate(composite) || + !!composite.children.find((childComposite) => + _compositeHasDescendant(childComposite), + ); + + return _compositeHasDescendant; +}; + +export { compositeHasDescendant }; diff --git a/src/common/utils/composite/find-composite/find-composite.test.ts b/src/common/utils/composite/find-composite/find-composite.test.ts new file mode 100644 index 0000000000..5b4147070e --- /dev/null +++ b/src/common/utils/composite/find-composite/find-composite.test.ts @@ -0,0 +1,91 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Composite } from "../get-composite/get-composite"; +import { findComposite } from "./find-composite"; +import { getCompositeFor } from "../get-composite/get-composite"; + +describe("find-composite", () => { + let composite: Composite<{ id: string; parentId?: string }>; + + beforeEach(() => { + const getComposite = getCompositeFor<{ + id: string; + parentId?: string; + }>({ + rootId: "some-root-id", + getId: (x) => x.id, + getParentId: (x) => x.parentId, + }); + + composite = getComposite([ + { id: "some-root-id" }, + { id: "some-child-id", parentId: "some-root-id" }, + { id: "some-grandchild-id", parentId: "some-child-id" }, + { id: "some-other-grandchild-id", parentId: "some-child-id" }, + ]); + }); + + it("when finding root using path, does so", () => { + const actual = findComposite("some-root-id")(composite); + + expect(actual.id).toBe("some-root-id"); + }); + + it("when finding child using path, does so", () => { + const actual = findComposite("some-root-id", "some-child-id")(composite); + + expect(actual.id).toBe("some-child-id"); + }); + + it("when finding grandchild using path, does so", () => { + const actual = findComposite( + "some-root-id", + "some-child-id", + "some-grandchild-id", + )(composite); + + expect(actual.id).toBe("some-grandchild-id"); + }); + + it("when finding with non existing leaf-level path, throws", () => { + expect(() => { + findComposite( + "some-root-id", + "some-child-id", + "some-non-existing-grandchild-id", + )(composite); + }).toThrow(`Tried to find 'some-root-id -> some-child-id -> some-non-existing-grandchild-id' from a composite, but found nothing. + +Node 'some-root-id -> some-child-id' had only following children: +some-grandchild-id +some-other-grandchild-id`); + }); + + it("when finding with non-existing mid-level path, throws", () => { + expect(() => { + findComposite( + "some-root-id", + "some-non-existing-child-id", + "some-non-existing-grandchild-id", + )(composite); + }).toThrow(`Tried to find 'some-root-id -> some-non-existing-child-id -> some-non-existing-grandchild-id' from a composite, but found nothing. + +Node 'some-root-id' had only following children: +some-child-id`); + }); + + it("when finding with non-existing root-level path, throws", () => { + expect(() => { + findComposite( + "some-non-existing-root-id", + "some-non-existing-child-id", + "some-non-existing-grandchild-id", + )(composite); + }).toThrow(`Tried to find 'some-non-existing-root-id -> some-non-existing-child-id -> some-non-existing-grandchild-id' from a composite, but found nothing. + +Node 'some-root-id' had only following children: +some-child-id`); + }); +}); diff --git a/src/common/utils/composite/find-composite/find-composite.ts b/src/common/utils/composite/find-composite/find-composite.ts new file mode 100644 index 0000000000..71dcf5baa3 --- /dev/null +++ b/src/common/utils/composite/find-composite/find-composite.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Composite } from "../get-composite/get-composite"; + +const _findComposite = (currentLeftIds: string[], currentId: string, currentRightIds: string[], composite: Composite): Composite => { + const [nextId, ...nextRightIds] = currentRightIds; + const nextLeftIds = [...currentLeftIds, currentId]; + + if (currentRightIds.length === 0 && composite.id === currentId) { + return composite; + } + + const foundChildComposite = composite.children.find((child) => child.id === nextId); + + if (foundChildComposite) { + return _findComposite(nextLeftIds, nextId, nextRightIds, foundChildComposite); + } + + const fullPathString = [...currentLeftIds, currentId, ...currentRightIds].join(" -> "); + + throw new Error(`Tried to find '${fullPathString}' from a composite, but found nothing. + +Node '${[...currentLeftIds, composite.id].join(" -> ")}' had only following children: +${composite.children.map((child) => child.id).join("\n")}`); +}; + +export const findComposite = + (...path: string[]) => + (composite: Composite): Composite => { + const [currentId, ...rightIds] = path; + const leftIds: string[] = []; + + return _findComposite(leftIds, currentId, rightIds, composite); + }; diff --git a/src/common/utils/composite/get-composite-normalization/get-composite-normalization.test.ts b/src/common/utils/composite/get-composite-normalization/get-composite-normalization.test.ts new file mode 100644 index 0000000000..ac88973251 --- /dev/null +++ b/src/common/utils/composite/get-composite-normalization/get-composite-normalization.test.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getCompositeNormalization } from "./get-composite-normalization"; +import { getCompositeFor } from "../get-composite/get-composite"; + + +describe("get-composite-normalization", () => { + it("given a composite, flattens it to paths and composites", () => { + const someRootItem = { + id: "some-root-id", + parentId: undefined, + }; + + const someItem = { + id: "some-id", + parentId: "some-root-id", + }; + + const someNestedItem = { + id: "some-child-id", + parentId: "some-id", + }; + + const items = [someRootItem, someItem, someNestedItem]; + + const getComposite = getCompositeFor<{ + id: string; + parentId?: string; + orderNumber?: number; + }>({ + rootId: "some-root-id", + getId: (x) => x.id, + getParentId: (x) => x.parentId, + }); + + const composite = getComposite(items); + + const actual = getCompositeNormalization(composite); + + expect(actual).toEqual([ + [["some-root-id"], expect.objectContaining({ value: someRootItem })], + + [["some-root-id", "some-id"], expect.objectContaining({ value: someItem })], + + [ + ["some-root-id", "some-id", "some-child-id"], + expect.objectContaining({ value: someNestedItem }), + ], + ]); + }); +}); diff --git a/src/common/utils/composite/get-composite-normalization/get-composite-normalization.ts b/src/common/utils/composite/get-composite-normalization/get-composite-normalization.ts new file mode 100644 index 0000000000..eb5628171a --- /dev/null +++ b/src/common/utils/composite/get-composite-normalization/get-composite-normalization.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Composite } from "../get-composite/get-composite"; + +export const getCompositeNormalization = (composite: Composite) => { + const _normalizeComposite = ( + composite: Composite, + previousPath: string[] = [], + ): (readonly [path: string[], composite: Composite])[] => { + const currentPath = [...previousPath, composite.id]; + + const pathAndCompositeTuple = [currentPath, composite] as const; + + return [ + pathAndCompositeTuple, + + ...composite.children.flatMap((child) => + _normalizeComposite(child, currentPath), + ), + ]; + }; + + return _normalizeComposite(composite); +}; diff --git a/src/common/utils/composite/get-composite-paths/get-composite-paths.test.ts b/src/common/utils/composite/get-composite-paths/get-composite-paths.test.ts new file mode 100644 index 0000000000..079f7e1b83 --- /dev/null +++ b/src/common/utils/composite/get-composite-paths/get-composite-paths.test.ts @@ -0,0 +1,71 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getCompositePaths } from "./get-composite-paths"; +import { sortBy } from "lodash/fp"; +import { getCompositeFor } from "../get-composite/get-composite"; + +describe("get-composite-paths", () => { + it("given composite with transformed children, returns paths of transformed children in hierarchical order", () => { + const someRootItem = { + id: "some-root-id", + }; + + const someChildItem1 = { + id: "some-child-id-1", + parentId: "some-root-id", + orderNumber: 1, + }; + + const someChildItem2 = { + id: "some-child-id-2", + parentId: "some-root-id", + orderNumber: 2, + }; + + const someGrandchildItem1 = { + id: "some-grandchild-id-1", + parentId: "some-child-id-1", + orderNumber: 1, + }; + + const someGrandchildItem2 = { + id: "some-grandchild-id-2", + parentId: "some-child-id-1", + orderNumber: 2, + }; + + const items = [ + someRootItem, + // Note: not in order yet. + someChildItem2, + someChildItem1, + someGrandchildItem2, + someGrandchildItem1, + ]; + + const getComposite = getCompositeFor<{ + id: string; + parentId?: string; + orderNumber?: number; + }>({ + rootId: "some-root-id", + getId: (x) => x.id, + getParentId: (x) => x.parentId, + transformChildren: children => sortBy(child => child.orderNumber, children), + }); + + const composite = getComposite(items); + + const actual = getCompositePaths(composite); + + expect(actual).toEqual([ + ["some-root-id"], + ["some-root-id", "some-child-id-1"], + ["some-root-id", "some-child-id-1", "some-grandchild-id-1"], + ["some-root-id", "some-child-id-1", "some-grandchild-id-2"], + ["some-root-id", "some-child-id-2"], + ]); + }); +}); diff --git a/src/common/utils/composite/get-composite-paths/get-composite-paths.ts b/src/common/utils/composite/get-composite-paths/get-composite-paths.ts new file mode 100644 index 0000000000..2b97467431 --- /dev/null +++ b/src/common/utils/composite/get-composite-paths/get-composite-paths.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { pipeline } from "@ogre-tools/fp"; +import { map } from "lodash/fp"; +import type { Composite } from "../get-composite/get-composite"; +import { getCompositeNormalization } from "../get-composite-normalization/get-composite-normalization"; + +export const getCompositePaths = ( + composite: Composite, +): string[][] => pipeline(composite, getCompositeNormalization, map(([path]) => path)); diff --git a/src/common/utils/composite/get-composite/get-composite.test.ts b/src/common/utils/composite/get-composite/get-composite.test.ts new file mode 100644 index 0000000000..594d0f3d01 --- /dev/null +++ b/src/common/utils/composite/get-composite/get-composite.test.ts @@ -0,0 +1,363 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { Composite } from "./get-composite"; +import { getCompositePaths } from "../get-composite-paths/get-composite-paths"; +import { sortBy } from "lodash/fp"; +import { getCompositeFor } from "./get-composite"; + +interface SomeItem { + id: string; + parentId?: string; + orderNumber?: number; +} + +describe("get-composite", () => { + it("given items and an explicit root id, creates a composite", () => { + const someRootItem = { + id: "some-root-id", + someProperty: "some-root-content", + }; + + const someIrrelevantRootItem = { + id: "some-irrelevant-root-id", + someProperty: "some-other-root-content", + }; + + const someItem = { + id: "some-id", + parentId: "some-root-id", + someProperty: "some-content", + }; + + const someNestedItem = { + id: "some-nested-id", + parentId: "some-id", + someProperty: "some-nested-content", + }; + + const items = [someRootItem, someIrrelevantRootItem, someItem, someNestedItem]; + + const getComposite = getCompositeFor({ + rootId: "some-root-id", + getId: (x) => x.id, + getParentId: (x) => x.parentId, + }); + + const composite = getComposite(items); + + expect(composite).toEqual({ + id: "some-root-id", + value: someRootItem, + + children: [ + { + id: "some-id", + parentId: "some-root-id", + value: someItem, + + children: [ + { + id: "some-nested-id", + parentId: "some-id", + value: someNestedItem, + children: [], + }, + ], + }, + ], + }); + }); + + it("given items and implicit root, creates a composite", () => { + const someRootItem = { + id: "some-root-id", + someProperty: "some-root-content", + // Notice: no "parentId" makes this the implicit root. + parentId: undefined, + }; + + const someItem = { + id: "some-id", + parentId: "some-root-id", + someProperty: "some-content", + }; + + const someNestedItem = { + id: "some-nested-id", + parentId: "some-id", + someProperty: "some-nested-content", + }; + + const items = [someRootItem, someItem, someNestedItem]; + + const getComposite = getCompositeFor({ + // Notice: no root id + // rootId: "some-root-id", + getId: (x) => x.id, + getParentId: (x) => x.parentId, + }); + + const composite = getComposite(items); + + expect(composite).toEqual({ + id: "some-root-id", + value: someRootItem, + + children: [ + { + id: "some-id", + parentId: "some-root-id", + value: someItem, + + children: [ + { + id: "some-nested-id", + parentId: "some-id", + value: someNestedItem, + children: [], + }, + ], + }, + ], + }); + }); + + it("given items and an unspecified root id and multiple items without parent as root, throws", () => { + const someRootItem = { + id: "some-root-id", + // Notice: no "parentId" makes this a root. + parentId: undefined, + }; + + const someOtherRootItem = { + id: "some-other-root-id", + // Notice: no "parentId" makes also this a root. + parentId: undefined, + }; + + const items = [someRootItem, someOtherRootItem]; + + const getComposite = getCompositeFor({ + getId: (x) => x.id, + getParentId: (x) => x.parentId, + }); + + expect(() => { + getComposite(items); + }).toThrow( + 'Tried to get a composite, but multiple roots where encountered: "some-root-id", "some-other-root-id"', + ); + }); + + it("given non-unique ids, throws", () => { + const someItem = { + id: "some-id", + parentId: "irrelevant", + }; + + const someOtherItem = { + id: "some-id", + parentId: "irrelevant", + }; + + const items = [someItem, someOtherItem]; + + const getComposite = getCompositeFor({ + getId: (x) => x.id, + getParentId: (x) => x.parentId, + }); + + expect(() => { + getComposite(items); + }).toThrow( + 'Tried to get a composite but encountered non-unique ids: "some-id"', + ); + }); + + it("given items with missing parent ids, when creating composite without handling for unknown parents, throws", () => { + const someItem = { + id: "some-id", + parentId: undefined, + }; + + const someItemWithMissingParentId = { + id: "some-other-id", + parentId: "some-missing-id", + }; + + const items = [someItem, someItemWithMissingParentId]; + + const getComposite = getCompositeFor({ + getId: (x) => x.id, + getParentId: (x) => x.parentId, + }); + + expect(() => { + getComposite(items); + }).toThrow( + `Tried to get a composite but encountered missing parent ids: "some-missing-id". + +Available parent ids are: +"some-id", +"some-other-id"`, + ); + }); + + describe("given items with missing parents, when creating composite with handling for missing parents", () => { + let composite: Composite; + let handleMissingParentIdMock: jest.Mock; + + beforeEach(() => { + const someItem = { + id: "some-root-id", + }; + + const someItemWithMissingParentId = { + id: "some-orphan-id", + // Note: the item corresponding to this id does not exist, + // making this item have a "missing parent". + parentId: "some-missing-id", + }; + + const items = [someItem, someItemWithMissingParentId]; + + handleMissingParentIdMock = jest.fn(); + + const getComposite = getCompositeFor({ + getId: (x) => x.id, + getParentId: (x) => x.parentId, + handleMissingParentIds: handleMissingParentIdMock, + }); + + composite = getComposite(items); + }); + + it("creates composite without the orphan item, and without throwing", () => { + const paths = getCompositePaths(composite); + + expect(paths).toEqual([["some-root-id"]]); + }); + + it("handles the missing parent ids", () => { + expect(handleMissingParentIdMock).toHaveBeenCalledWith({ + missingParentIds: ["some-missing-id"], + availableParentIds: ["some-root-id", "some-orphan-id"], + }); + }); + }); + + it("given items with same id and parent id, throws", () => { + const someItem = { + id: "some-id", + parentId: "some-id", + }; + + const someRoot = { + id: "root", + parentId: undefined, + }; + + const items = [someItem, someRoot]; + + const getComposite = getCompositeFor({ + getId: (x) => x.id, + getParentId: (x) => x.parentId, + }); + + expect(() => { + getComposite(items); + }).toThrow( + 'Tried to get a composite, but found items with self as parent: "some-id"', + ); + }); + + it("given undefined ids, throws", () => { + const root = { + parentId: undefined, + id: "some-root", + }; + + const someItem = { + parentId: "some-root", + id: undefined, + }; + + const someOtherItem = { + parentId: "some-root", + id: undefined, + }; + + const items = [root, someItem, someOtherItem]; + + const getComposite = getCompositeFor({ + getId: (x) => x.id, + getParentId: (x) => x.parentId, + }); + + expect(() => { + getComposite(items); + }).toThrow("Tried to get a composite but encountered 2 undefined ids"); + }); + + it("given transformed children, creates a composite with transformed children", () => { + const someRootItem = { + id: "some-root-id", + orderNumber: 1, + }; + + const someItem1 = { + id: "some-id-1", + parentId: "some-root-id", + orderNumber: 1, + }; + + const someItem2 = { + id: "some-id-2", + parentId: "some-root-id", + orderNumber: 2, + }; + + const someChildItem1 = { + id: "some-child-id-1", + parentId: "some-id-1", + orderNumber: 1, + }; + + const someChildItem2 = { + id: "some-child-id-2", + parentId: "some-id-1", + orderNumber: 2, + }; + + const items = [ + someRootItem, + // Note: not in order yet. + someItem2, + someItem1, + someChildItem2, + someChildItem1, + ]; + + const getComposite = getCompositeFor({ + getId: (x) => x.id, + getParentId: (x) => x.parentId, + transformChildren: (things) => + sortBy((thing) => thing.orderNumber, things), + }); + + const composite = getComposite(items); + + const orderedPaths = getCompositePaths(composite); + + expect(orderedPaths).toEqual([ + ["some-root-id"], + ["some-root-id", "some-id-1"], + ["some-root-id", "some-id-1", "some-child-id-1"], + ["some-root-id", "some-id-1", "some-child-id-2"], + ["some-root-id", "some-id-2"], + ]); + }); +}); diff --git a/src/common/utils/composite/get-composite/get-composite.ts b/src/common/utils/composite/get-composite/get-composite.ts new file mode 100644 index 0000000000..7e499ad40a --- /dev/null +++ b/src/common/utils/composite/get-composite/get-composite.ts @@ -0,0 +1,147 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { pipeline } from "@ogre-tools/fp"; +import { + countBy, + filter, + toPairs, + nth, + map, + uniq, + without, + compact, + identity, +} from "lodash/fp"; + +export interface Composite { + id: string; + parentId: string | undefined; + value: T; + children: Composite[]; +} + +interface Configuration { + rootId?: string; + getId: (thing: T) => string; + getParentId: (thing: T) => string | undefined; + transformChildren?: (things: T[]) => T[]; + handleMissingParentIds?: (parentIdsForHandling: ParentIdsForHandling) => void; +} + +export const getCompositeFor = ({ + rootId = undefined, + getId, + getParentId, + transformChildren = identity, + handleMissingParentIds = throwMissingParentIds, +}: Configuration) => (source: T[]) => { + const undefinedIds = pipeline( + source, + filter((x) => getId(x) === undefined), + ); + + if (undefinedIds.length) { + throw new Error( + `Tried to get a composite but encountered ${undefinedIds.length} undefined ids`, + ); + } + + const selfReferencingIds = pipeline( + source, + filter((x) => getId(x) === getParentId(x)), + map(getId), + ); + + if (selfReferencingIds.length) { + throw new Error( + `Tried to get a composite, but found items with self as parent: "${selfReferencingIds.join( + '", ', + )}"`, + ); + } + + const duplicateIds = pipeline( + source, + countBy(getId), + toPairs, + filter(([, count]) => count > 1), + map(nth(0)), + ); + + if (duplicateIds.length) { + throw new Error( + `Tried to get a composite but encountered non-unique ids: "${duplicateIds + .map((x) => String(x)) + .join('", "')}"`, + ); + } + + const allIds = pipeline(source, map(getId)); + + const allParentIds = pipeline(source, map(getParentId), uniq, compact); + + const missingParentIds = without(allIds, allParentIds); + + if (missingParentIds.length) { + handleMissingParentIds({ missingParentIds, availableParentIds: allIds }); + } + + const toComposite = (thing: T): Composite => { + const thingId = getId(thing); + + return { + id: thingId, + parentId: getParentId(thing), + value: thing, + + children: pipeline( + source, + + filter((childThing) => { + const parentId = getParentId(childThing); + + return parentId === thingId; + }), + + transformChildren, + + map(toComposite), + ), + }; + }; + + const isRootId = rootId + ? (thing: T) => getId(thing) === rootId + : (thing: T) => getParentId(thing) === undefined; + + const roots = source.filter(isRootId); + + if (roots.length > 1) { + throw new Error( + `Tried to get a composite, but multiple roots where encountered: "${roots + .map(getId) + .join('", "')}"`, + ); + } + + return toComposite(roots[0]); + }; + +interface ParentIdsForHandling { + missingParentIds: string[]; + availableParentIds: string[]; +} + +const throwMissingParentIds = ({ + missingParentIds, + availableParentIds, +}: ParentIdsForHandling) => { + throw new Error( + `Tried to get a composite but encountered missing parent ids: "${missingParentIds.join( + '", "', + )}".\n\nAvailable parent ids are:\n"${availableParentIds.join('",\n"')}"`, + ); +}; diff --git a/src/common/utils/composite/interfaces.ts b/src/common/utils/composite/interfaces.ts new file mode 100644 index 0000000000..1c29a0e4ce --- /dev/null +++ b/src/common/utils/composite/interfaces.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export interface ParentOfChildComposite { + id: Id; +} + +export interface ChildOfParentComposite { + parentId: ParentId; +} + +export type RootComposite = + & { parentId: undefined } + & ParentOfChildComposite; diff --git a/src/common/utils/date/get-current-date-time.ts b/src/common/utils/date/get-current-date-time.ts index bf3df2bd78..aa4d5e7fa3 100644 --- a/src/common/utils/date/get-current-date-time.ts +++ b/src/common/utils/date/get-current-date-time.ts @@ -5,3 +5,7 @@ import moment from "moment"; export const getCurrentDateTime = () => moment().utc().format(); + +export const getMillisecondsFromUnixEpoch = () => Date.now(); + +export const getSecondsFromUnixEpoch = () => Math.floor(getMillisecondsFromUnixEpoch() / 1000); diff --git a/src/common/utils/disposer.ts b/src/common/utils/disposer.ts index 05a349ab4d..2949a7ae35 100644 --- a/src/common/utils/disposer.ts +++ b/src/common/utils/disposer.ts @@ -3,6 +3,8 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { SingleOrMany } from "./types"; + export interface Disposer { @@ -17,9 +19,9 @@ export interface ExtendableDisposer extends Disposer { push(...vals: (Disposer | ExtendableDisposer | Disposable)[]): void; } -export function disposer(...items: (Disposer | Disposable | undefined | null)[]): ExtendableDisposer { +export function disposer(...items: SingleOrMany[]): ExtendableDisposer { return Object.assign(() => { - for (const item of items) { + for (const item of items.flat()) { if (!item) { continue; } diff --git a/src/common/utils/downloadFile.ts b/src/common/utils/downloadFile.ts deleted file mode 100644 index 5f3e658aa6..0000000000 --- a/src/common/utils/downloadFile.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import request from "request"; -import type { JsonValue } from "type-fest"; -import { parse } from "./json"; - -export interface DownloadFileOptions { - url: string; - gzip?: boolean; - timeout?: number; -} - -export interface DownloadFileTicket { - url: string; - promise: Promise; - cancel(): void; -} - -export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket { - const fileChunks: Buffer[] = []; - const req = request(url, { gzip, timeout }); - const promise: Promise = new Promise((resolve, reject) => { - req.on("data", (chunk: Buffer) => { - fileChunks.push(chunk); - }); - req.once("error", err => { - reject({ url, err }); - }); - req.once("complete", () => { - resolve(Buffer.concat(fileChunks)); - }); - }); - - return { - url, - promise, - cancel() { - req.abort(); - }, - }; -} - -export function downloadJson(args: DownloadFileOptions): DownloadFileTicket { - const { promise, ...rest } = downloadFile(args); - - return { - promise: promise.then(res => parse(res.toString())), - ...rest, - }; -} diff --git a/src/common/utils/find-exactly-one/find-exactly-one.test.ts b/src/common/utils/find-exactly-one/find-exactly-one.test.ts new file mode 100644 index 0000000000..08dbb9ce72 --- /dev/null +++ b/src/common/utils/find-exactly-one/find-exactly-one.test.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { findExactlyOne } from "./find-exactly-one"; + +describe("find-exactly-one", () => { + it("when predicate matches to single item, returns the item", () => { + const actual = findExactlyOne((item) => item === "some-item")([ + "some-item", + "some-other-item", + ]); + + expect(actual).toBe("some-item"); + }); + + it("when predicate matches to many items, throws", () => { + expect(() => { + findExactlyOne((item) => item === "some-item")([ + "some-item", + "some-item", + ]); + }).toThrow("Tried to find exactly one, but found many"); + }); + + it("when predicate does not match, throws", () => { + expect(() => { + findExactlyOne((item) => item === "some-item")([ + "some-other-item", + ]); + }).toThrow("Tried to find exactly one, but didn't find any"); + }); +}); diff --git a/src/common/utils/find-exactly-one/find-exactly-one.ts b/src/common/utils/find-exactly-one/find-exactly-one.ts new file mode 100644 index 0000000000..181720d8b1 --- /dev/null +++ b/src/common/utils/find-exactly-one/find-exactly-one.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +export const findExactlyOne = (predicate: (item: T) => boolean) => (collection: T[]): T => { + const itemsFound = collection.filter(predicate); + + if (!itemsFound.length) { + throw new Error( + "Tried to find exactly one, but didn't find any", + ); + } + + if (itemsFound.length > 1) { + throw new Error( + "Tried to find exactly one, but found many", + ); + } + + return itemsFound[0]; +}; diff --git a/src/common/utils/get-startable-stoppable.test.ts b/src/common/utils/get-startable-stoppable.test.ts index 24b9e1862d..dc8b24dd43 100644 --- a/src/common/utils/get-startable-stoppable.test.ts +++ b/src/common/utils/get-startable-stoppable.test.ts @@ -2,21 +2,17 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { AsyncFnMock } from "@async-fn/jest"; -import asyncFn from "@async-fn/jest"; +import type { StartableStoppable } from "./get-startable-stoppable"; import { getStartableStoppable } from "./get-startable-stoppable"; -import { getPromiseStatus } from "../test-utils/get-promise-status"; -import { flushPromises } from "../test-utils/flush-promises"; describe("getStartableStoppable", () => { - let stopMock: AsyncFnMock<() => Promise>; - let startMock: AsyncFnMock<() => Promise<() => Promise>>; - let actual: { stop: () => Promise; start: () => Promise; started: boolean }; + let stopMock: jest.MockedFunction<() => void>; + let startMock: jest.MockedFunction<() => () => void>; + let actual: StartableStoppable; beforeEach(() => { - stopMock = asyncFn(); - startMock = asyncFn(); - + stopMock = jest.fn(); + startMock = jest.fn().mockImplementation(() => stopMock); actual = getStartableStoppable("some-id", startMock); }); @@ -29,7 +25,7 @@ describe("getStartableStoppable", () => { }); it("when stopping before ever starting, throws", () => { - expect(actual.stop).rejects.toThrow("Tried to stop \"some-id\", but it has not started yet."); + expect(() => actual.stop()).toThrow("Tried to stop \"some-id\", but it is already stopped."); }); it("is not started", () => { @@ -37,199 +33,30 @@ describe("getStartableStoppable", () => { }); describe("when started", () => { - let startPromise: Promise; - beforeEach(() => { - startPromise = actual.start(); + actual.start(); }); - it("starts starting", () => { + it("calls start function", () => { expect(startMock).toHaveBeenCalled(); }); - it("starting does not resolve yet", async () => { - const promiseStatus = await getPromiseStatus(startPromise); - - expect(promiseStatus.fulfilled).toBe(false); + it("is started", () => { + expect(actual.started).toBe(true); }); - it("is not started yet", () => { - expect(actual.started).toBe(false); - }); - - describe("when started again before the start has finished", () => { - let error: Error; - + describe("when stopped", () => { beforeEach(() => { - startMock.mockClear(); - - actual.start().catch((e) => { error = e; }); + actual.stop(); }); - it("does not start starting again", () => { - expect(startMock).not.toHaveBeenCalled(); + it("calls stop function", () => { + expect(stopMock).toBeCalled(); }); - it("throws", () => { - expect(error.message).toBe("Tried to start \"some-id\", but it is already being started."); - }); - }); - - describe("when starting finishes", () => { - beforeEach(async () => { - await startMock.resolve(stopMock); - }); - - it("is started", () => { - expect(actual.started).toBe(true); - }); - - it("starting resolves", async () => { - const promiseStatus = await getPromiseStatus(startPromise); - - expect(promiseStatus.fulfilled).toBe(true); - }); - - it("when started again, throws", () => { - expect(actual.start).rejects.toThrow("Tried to start \"some-id\", but it has already started."); - }); - - it("does not stop yet", () => { - expect(stopMock).not.toHaveBeenCalled(); - }); - - describe("when stopped", () => { - let stopPromise: Promise; - - beforeEach(() => { - stopPromise = actual.stop(); - }); - - it("starts stopping", () => { - expect(stopMock).toHaveBeenCalled(); - }); - - it("stopping does not resolve yet", async () => { - const promiseStatus = await getPromiseStatus(stopPromise); - - expect(promiseStatus.fulfilled).toBe(false); - }); - - it("is not stopped yet", () => { - expect(actual.started).toBe(true); - }); - - describe("when stopping finishes", () => { - beforeEach(async () => { - await stopMock.resolve(); - }); - - it("is not started", () => { - expect(actual.started).toBe(false); - }); - - it("stopping resolves", async () => { - const promiseStatus = await getPromiseStatus(stopPromise); - - expect(promiseStatus.fulfilled).toBe(true); - }); - - it("when stopped again, throws", () => { - expect(actual.stop).rejects.toThrow("Tried to stop \"some-id\", but it has already stopped."); - }); - - describe("when started again", () => { - beforeEach( - () => { - startMock.mockClear(); - - actual.start(); - }); - - it("starts", () => { - expect(startMock).toHaveBeenCalled(); - }); - - it("is not started yet", () => { - expect(actual.started).toBe(false); - }); - - describe("when starting finishes", () => { - beforeEach(async () => { - await startMock.resolve(stopMock); - }); - - it("is started", () => { - expect(actual.started).toBe(true); - }); - - it("when stopped again, starts stopping again", async () => { - stopMock.mockClear(); - - actual.stop(); - - await flushPromises(); - - expect(stopMock).toHaveBeenCalled(); - }); - }); - }); - }); - }); - }); - - describe("when stopped before starting finishes", () => { - let stopPromise: Promise; - - beforeEach(() => { - stopPromise = actual.stop(); - }); - - it("does not resolve yet", async () => { - const promiseStatus = await getPromiseStatus(stopPromise); - - expect(promiseStatus.fulfilled).toBe(false); - }); - - it("is not started yet", () => { + it("is stopped", () => { expect(actual.started).toBe(false); }); - - describe("when starting finishes", () => { - beforeEach(async () => { - await startMock.resolve(stopMock); - }); - - it("starts stopping", () => { - expect(stopMock).toHaveBeenCalled(); - }); - - it("is not stopped yet", () => { - expect(actual.started).toBe(true); - }); - - it("does not resolve yet", async () => { - const promiseStatus = await getPromiseStatus(stopPromise); - - expect(promiseStatus.fulfilled).toBe(false); - }); - - describe("when stopping finishes", () => { - beforeEach(async () => { - await stopMock.resolve(); - }); - - it("is stopped", () => { - expect(actual.started).toBe(false); - }); - - it("resolves", async () => { - const promiseStatus = await getPromiseStatus(stopPromise); - - expect(promiseStatus.fulfilled).toBe(true); - }); - }); - }); }); }); }); diff --git a/src/common/utils/get-startable-stoppable.ts b/src/common/utils/get-startable-stoppable.ts index 5590157ab8..05d8b4d9af 100644 --- a/src/common/utils/get-startable-stoppable.ts +++ b/src/common/utils/get-startable-stoppable.ts @@ -3,58 +3,43 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -type Stopper = () => Promise | void; -type Starter = () => Promise | Stopper; +export type Stopper = () => void; +export type Starter = () => Stopper; -export const getStartableStoppable = ( - id: string, - startAndGetStopCallback: Starter, -) => { +export interface StartableStoppable { + readonly started: boolean; + start: () => void; + stop: () => void; +} + +type StartableStoppableState = "stopped" | "started" | "starting"; + +export function getStartableStoppable(id: string, startAndGetStopper: Starter): StartableStoppable { let stop: Stopper; - let stopped = false; - let started = false; - let starting = false; - let startingPromise: Promise | Stopper; + let state: StartableStoppableState = "stopped"; return { get started() { - return started; + return state === "started"; }, - start: async () => { - if (starting) { - throw new Error(`Tried to start "${id}", but it is already being started.`); + start: () => { + if (state !== "stopped") { + throw new Error(`Tried to start "${id}", but it is already ${state}.`); } - starting = true; - - if (started) { - throw new Error(`Tried to start "${id}", but it has already started.`); - } - - startingPromise = startAndGetStopCallback(); - stop = await startingPromise; - - stopped = false; - started = true; - starting = false; + state = "starting"; + stop = startAndGetStopper(); + state = "started"; }, - stop: async () => { - await startingPromise; - - if (stopped) { - throw new Error(`Tried to stop "${id}", but it has already stopped.`); + stop: () => { + if (state !== "started") { + throw new Error(`Tried to stop "${id}", but it is already ${state}.`); } - if (!started) { - throw new Error(`Tried to stop "${id}", but it has not started yet.`); - } - - await stop(); - - started = false; - stopped = true; + stop(); + state = "stopped"; }, }; -}; +} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index e16f803c56..36e77c6e79 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -14,7 +14,6 @@ export * from "./convertMemory"; export * from "./debouncePromise"; export * from "./delay"; export * from "./disposer"; -export * from "./downloadFile"; export * from "./escapeRegExp"; export * from "./formatDuration"; export * from "./getRandId"; @@ -22,7 +21,6 @@ export * from "./hash-set"; export * from "./n-fircate"; export * from "./noop"; export * from "./observable-crate/impl"; -export * from "./paths"; export * from "./promise-exec"; export * from "./readonly"; export * from "./reject-promise"; diff --git a/src/common/utils/iter.ts b/src/common/utils/iter.ts index 05d097ed78..9752e56dc5 100644 --- a/src/common/utils/iter.ts +++ b/src/common/utils/iter.ts @@ -11,6 +11,7 @@ interface Iterator { find(fn: (val: T) => unknown): T | undefined; collect(fn: (values: Iterable) => U): U; map(fn: (val: T) => U): Iterator; + flatMap(fn: (val: T) => U[]): Iterator; join(sep?: string): string; } @@ -19,6 +20,7 @@ export function pipeline(src: IterableIterator): Iterator { filter: (fn) => pipeline(filter(src, fn)), filterMap: (fn) => pipeline(filterMap(src, fn)), map: (fn) => pipeline(map(src, fn)), + flatMap: (fn) => pipeline(flatMap(src, fn)), find: (fn) => find(src, fn), join: (sep) => join(src, sep), collect: (fn) => fn(src), diff --git a/src/common/utils/lazy-initialized.ts b/src/common/utils/lazy-initialized.ts deleted file mode 100644 index 62247873fe..0000000000 --- a/src/common/utils/lazy-initialized.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -/** - * A OnceCell is an object that wraps some function that produces a value. - * - * It then only calls the function on the first call to `get()` and returns the - * same instance/value on every subsequent call. - */ -export interface LazyInitialized { - get(): T; -} - -/** - * A function to make a `OnceCell` - */ -export function lazyInitialized(builder: () => T): LazyInitialized { - let value: T | undefined; - let called = false; - - return { - get() { - if (called) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return value!; - } - - value = builder(); - called = true; - - return value; - }, - }; -} diff --git a/src/common/utils/objects.ts b/src/common/utils/objects.ts index 8fe95be46d..9b6355d3e7 100644 --- a/src/common/utils/objects.ts +++ b/src/common/utils/objects.ts @@ -17,6 +17,7 @@ export function keys(obj: Record): K[] { return Object.keys(obj) as K[]; } +export function entries(obj: Partial> | null | undefined): [K, V][]; export function entries(obj: Partial> | null | undefined): [K, V][]; export function entries(obj: Record | null | undefined): [K, V][]; diff --git a/src/common/utils/paths.ts b/src/common/utils/paths.ts deleted file mode 100644 index ed31e612fb..0000000000 --- a/src/common/utils/paths.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import path from "path"; -import os from "os"; - -export function resolveTilde(filePath: string) { - if (filePath === "~") { - return os.homedir(); - } - - if (filePath.startsWith("~/")) { - return `${os.homedir()}${filePath.slice(1)}`; - } - - return filePath; -} - -export function resolvePath(filePath: string): string { - return path.resolve(resolveTilde(filePath)); -} - -/** - * Checks if `testPath` represents a potential filesystem entry that would be - * logically "within" the `parentPath` directory. - * - * This function will return `true` in the above case, and `false` otherwise. - * It will return `false` if the two paths are the same (after resolving them). - * - * The function makes no FS calls and is platform dependant. Meaning that the - * results are only guaranteed to be correct for the platform you are running - * on. - * @param parentPath The known path of a directory - * @param testPath The path that is to be tested - */ -export function isLogicalChildPath(parentPath: string, testPath: string): boolean { - parentPath = path.resolve(parentPath); - testPath = path.resolve(testPath); - - if (parentPath === testPath) { - return false; - } - - while (testPath.length >= parentPath.length) { - if (testPath === parentPath) { - return true; - } - - testPath = path.dirname(testPath); - } - - return false; -} diff --git a/src/common/utils/readableStream.ts b/src/common/utils/readableStream.ts index 136e18c841..59fb5ae4f4 100644 --- a/src/common/utils/readableStream.ts +++ b/src/common/utils/readableStream.ts @@ -4,7 +4,6 @@ */ import { Readable } from "readable-stream"; -import type { ReadableStreamDefaultReadResult } from "stream/web"; import type { TypedArray } from "type-fest"; /** @@ -24,8 +23,8 @@ export class ReadableWebToNodeStream extends Readable { * Default web API stream reader * https://developer.mozilla.org/en-US/docs/Web/API/ReadableStreamDefaultReader */ - private reader: ReadableStreamReader; - private pendingRead?: Promise>; + private reader: ReadableStreamDefaultReader; + private pendingRead?: Promise>; /** * diff --git a/src/common/utils/resolve-system-proxy/resolve-system-proxy-channel.injectable.ts b/src/common/utils/resolve-system-proxy/resolve-system-proxy-channel.injectable.ts deleted file mode 100644 index 5dd17ddc6a..0000000000 --- a/src/common/utils/resolve-system-proxy/resolve-system-proxy-channel.injectable.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * 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 type { RequestChannel } from "../channel/request-channel-injection-token"; -import { requestChannelInjectionToken } from "../channel/request-channel-injection-token"; - -export type ResolveSystemProxyChannel = RequestChannel; - -const resolveSystemProxyChannelInjectable = getInjectable({ - id: "resolve-system-proxy-channel", - - instantiate: (): ResolveSystemProxyChannel => ({ - id: "resolve-system-proxy-channel", - }), - - injectionToken: requestChannelInjectionToken, -}); - -export default resolveSystemProxyChannelInjectable; diff --git a/src/common/utils/resolve-system-proxy/resolve-system-proxy-channel.ts b/src/common/utils/resolve-system-proxy/resolve-system-proxy-channel.ts new file mode 100644 index 0000000000..c823a8a8f9 --- /dev/null +++ b/src/common/utils/resolve-system-proxy/resolve-system-proxy-channel.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { RequestChannel } from "../channel/request-channel-listener-injection-token"; + +export type ResolveSystemProxyChannel = RequestChannel; + +export const resolveSystemProxyChannel: ResolveSystemProxyChannel = { + id: "resolve-system-proxy-channel", +}; diff --git a/src/common/utils/sort-compare.ts b/src/common/utils/sort-compare.ts index ee32ab45c4..35d88c01b9 100644 --- a/src/common/utils/sort-compare.ts +++ b/src/common/utils/sort-compare.ts @@ -3,11 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { SemVer } from "semver"; import semver, { coerce } from "semver"; -import * as iter from "./iter"; -import type { RawHelmChart } from "../k8s-api/endpoints/helm-charts.api"; -import logger from "../logger"; export enum Ordering { LESS = -1, @@ -49,52 +45,31 @@ export function sortCompare(left: T, right: T): Ordering { return Ordering.GREATER; } -interface ChartVersion { - version: string; - __version?: SemVer | null; -} - -export function sortCompareChartVersions(left: ChartVersion, right: ChartVersion): Ordering { - if (left.__version && right.__version) { - return semver.compare(right.__version, left.__version); - } - - if (!left.__version && right.__version) { - return Ordering.GREATER; - } - - if (left.__version && !right.__version) { - return Ordering.LESS; - } - - return sortCompare(left.version, right.version); -} - - - -export function sortCharts(charts: RawHelmChart[]) { - interface ExtendedHelmChart extends RawHelmChart { - __version?: SemVer | null; - } - - const chartsWithVersion = Array.from( - iter.map( - charts, - chart => { - const __version = coerce(chart.version, { loose: true }); - - if (!__version) { - logger.warn(`[HELM-SERVICE]: Version from helm chart is not loosely coercable to semver.`, { name: chart.name, version: chart.version, repo: chart.repo }); - } - - (chart as ExtendedHelmChart).__version = __version; - - return chart as ExtendedHelmChart; - }, - ), - ); - - return chartsWithVersion - .sort(sortCompareChartVersions) - .map(chart => (delete chart.__version, chart as RawHelmChart)); +/** + * This function sorts of list of items that have what should be a semver version formated string + * as the field `version` but if it is not loosely coercable to semver falls back to sorting them + * alphanumerically + */ +export function sortBySemverVersion(versioneds: T[]): T[] { + return versioneds + .map(versioned => ({ + __version: coerce(versioned.version, { loose: true }), + raw: versioned, + })) + .sort((left, right) => { + if (left.__version && right.__version) { + return semver.compare(right.__version, left.__version); + } + + if (!left.__version && right.__version) { + return Ordering.GREATER; + } + + if (left.__version && !right.__version) { + return Ordering.LESS; + } + + return sortCompare(left.raw.version, right.raw.version); + }) + .map(({ raw }) => raw); } diff --git a/src/common/utils/sync-box/channel-listener.injectable.ts b/src/common/utils/sync-box/channel-listener.injectable.ts new file mode 100644 index 0000000000..a97d95d726 --- /dev/null +++ b/src/common/utils/sync-box/channel-listener.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { syncBoxChannel } from "./channels"; +import { getMessageChannelListenerInjectable } from "../channel/message-channel-listener-injection-token"; +import syncBoxStateInjectable from "./sync-box-state.injectable"; + +const syncBoxChannelListenerInjectable = getMessageChannelListenerInjectable({ + id: "init", + channel: syncBoxChannel, + handler: (di) => ({ id, value }) => di.inject(syncBoxStateInjectable, id).set(value), +}); + +export default syncBoxChannelListenerInjectable; diff --git a/src/common/utils/sync-box/channels.ts b/src/common/utils/sync-box/channels.ts new file mode 100644 index 0000000000..4df0462dc3 --- /dev/null +++ b/src/common/utils/sync-box/channels.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { MessageChannel } from "../channel/message-channel-listener-injection-token"; +import type { RequestChannel } from "../channel/request-channel-listener-injection-token"; + +export type SyncBoxChannel = MessageChannel<{ id: string; value: any }>; + +export const syncBoxChannel: SyncBoxChannel = { + id: "sync-box-channel", +}; + +export type SyncBoxInitialValueChannel = RequestChannel< + void, + { id: string; value: any }[] +>; + +export const syncBoxInitialValueChannel: SyncBoxInitialValueChannel = { + id: "sync-box-initial-value-channel", +}; diff --git a/src/common/utils/sync-box/create-sync-box.injectable.ts b/src/common/utils/sync-box/create-sync-box.injectable.ts index 1328106e24..4a01fe71a0 100644 --- a/src/common/utils/sync-box/create-sync-box.injectable.ts +++ b/src/common/utils/sync-box/create-sync-box.injectable.ts @@ -5,17 +5,17 @@ import { getInjectable } from "@ogre-tools/injectable"; import type { IObservableValue } from "mobx"; import { computed } from "mobx"; -import syncBoxChannelInjectable from "./sync-box-channel.injectable"; -import { messageToChannelInjectionToken } from "../channel/message-to-channel-injection-token"; +import { syncBoxChannel } from "./channels"; +import { sendMessageToChannelInjectionToken } from "../channel/message-to-channel-injection-token"; import syncBoxStateInjectable from "./sync-box-state.injectable"; import type { SyncBox } from "./sync-box-injection-token"; +import { toJS } from "../toJS"; const createSyncBoxInjectable = getInjectable({ id: "create-sync-box", instantiate: (di) => { - const syncBoxChannel = di.inject(syncBoxChannelInjectable); - const messageToChannel = di.inject(messageToChannelInjectionToken); + const messageToChannel = di.inject(sendMessageToChannelInjectionToken); const getSyncBoxState = (id: string) => di.inject(syncBoxStateInjectable, id); return (id: string, initialValue: Value): SyncBox => { @@ -26,7 +26,7 @@ const createSyncBoxInjectable = getInjectable({ return { id, - value: computed(() => state.get()), + value: computed(() => toJS(state.get())), set: (value) => { state.set(value); diff --git a/src/common/utils/sync-box/handler.injectable.ts b/src/common/utils/sync-box/handler.injectable.ts new file mode 100644 index 0000000000..f520585474 --- /dev/null +++ b/src/common/utils/sync-box/handler.injectable.ts @@ -0,0 +1,19 @@ +/** + * 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 type { MessageChannelHandler } from "../channel/message-channel-listener-injection-token"; +import type { SyncBoxChannel } from "./channels"; +import syncBoxStateInjectable from "./sync-box-state.injectable"; + +const syncBoxChannelHandlerInjectable = getInjectable({ + id: "sync-box-channel-handler", + instantiate: (di): MessageChannelHandler => { + const getSyncBoxState = (id: string) => di.inject(syncBoxStateInjectable, id); + + return ({ id, value }) => getSyncBoxState(id)?.set(value); + }, +}); + +export default syncBoxChannelHandlerInjectable; diff --git a/src/common/utils/sync-box/sync-box-channel-listener.injectable.ts b/src/common/utils/sync-box/sync-box-channel-listener.injectable.ts deleted file mode 100644 index b603c85997..0000000000 --- a/src/common/utils/sync-box/sync-box-channel-listener.injectable.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * 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 type { SyncBoxChannel } from "./sync-box-channel.injectable"; -import syncBoxChannelInjectable from "./sync-box-channel.injectable"; -import syncBoxStateInjectable from "./sync-box-state.injectable"; -import type { MessageChannelListener } from "../channel/message-channel-listener-injection-token"; -import { messageChannelListenerInjectionToken } from "../channel/message-channel-listener-injection-token"; - -const syncBoxChannelListenerInjectable = getInjectable({ - id: "sync-box-channel-listener", - - instantiate: (di): MessageChannelListener => { - const getSyncBoxState = (id: string) => di.inject(syncBoxStateInjectable, id); - const channel = di.inject(syncBoxChannelInjectable); - - return { - channel, - - handler: ({ id, value }) => { - const target = getSyncBoxState(id); - - if (target) { - target.set(value); - } - }, - }; - }, - - injectionToken: messageChannelListenerInjectionToken, -}); - -export default syncBoxChannelListenerInjectable; diff --git a/src/common/utils/sync-box/sync-box-channel.injectable.ts b/src/common/utils/sync-box/sync-box-channel.injectable.ts deleted file mode 100644 index 9389a99867..0000000000 --- a/src/common/utils/sync-box/sync-box-channel.injectable.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * 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 type { MessageChannel } from "../channel/message-channel-injection-token"; -import { messageChannelInjectionToken } from "../channel/message-channel-injection-token"; - -export type SyncBoxChannel = MessageChannel<{ id: string; value: any }>; - -const syncBoxChannelInjectable = getInjectable({ - id: "sync-box-channel", - - instantiate: (): SyncBoxChannel => ({ - id: "sync-box-channel", - }), - - injectionToken: messageChannelInjectionToken, -}); - -export default syncBoxChannelInjectable; diff --git a/src/common/utils/sync-box/sync-box-initial-value-channel.injectable.ts b/src/common/utils/sync-box/sync-box-initial-value-channel.injectable.ts deleted file mode 100644 index 89374c3565..0000000000 --- a/src/common/utils/sync-box/sync-box-initial-value-channel.injectable.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * 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 type { RequestChannel } from "../channel/request-channel-injection-token"; -import { requestChannelInjectionToken } from "../channel/request-channel-injection-token"; - -export type SyncBoxInitialValueChannel = RequestChannel< - void, - { id: string; value: any }[] ->; - -const syncBoxInitialValueChannelInjectable = getInjectable({ - id: "sync-box-initial-value-channel", - - instantiate: (): SyncBoxInitialValueChannel => ({ - id: "sync-box-initial-value-channel", - }), - - injectionToken: requestChannelInjectionToken, -}); - -export default syncBoxInitialValueChannelInjectable; diff --git a/src/common/utils/tar.ts b/src/common/utils/tar.ts index d351ec2507..3102098976 100644 --- a/src/common/utils/tar.ts +++ b/src/common/utils/tar.ts @@ -5,7 +5,6 @@ // Helper for working with tarball files (.tar, .tgz) // Docs: https://github.com/npm/node-tar -import type { ExtractOptions, FileStat } from "tar"; import tar from "tar"; import path from "path"; import { parse } from "./json"; @@ -35,7 +34,7 @@ export function readFileFromTar({ tarPath, filePath, file: tarPath, filter: entryPath => path.normalize(entryPath) === filePath, sync: true, - onentry(entry: FileStat) { + onentry(entry) { entry.on("data", chunk => { fileChunks.push(chunk); }); @@ -62,18 +61,10 @@ export async function listTarEntries(filePath: string): Promise { await tar.list({ file: filePath, - onentry: (entry: FileStat) => { - entries.push(path.normalize(entry.path as unknown as string)); + onentry: (entry) => { + entries.push(path.normalize(entry.path)); }, }); return entries; } - -export function extractTar(filePath: string, opts: ExtractOptions & { sync?: boolean } = {}) { - return tar.extract({ - file: filePath, - cwd: path.dirname(filePath), - ...opts, - }); -} diff --git a/src/common/utils/tentative-parse-json.ts b/src/common/utils/tentative-parse-json.ts deleted file mode 100644 index a0cb089a74..0000000000 --- a/src/common/utils/tentative-parse-json.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { pipeline } from "@ogre-tools/fp"; -import { defaultTo } from "lodash/fp"; -import { withErrorSuppression } from "./with-error-suppression/with-error-suppression"; - -export const tentativeParseJson = (toBeParsed: any) => pipeline( - toBeParsed, - withErrorSuppression(JSON.parse), - defaultTo(toBeParsed), -); - - diff --git a/src/common/utils/tentative-stringify-json.ts b/src/common/utils/tentative-stringify-json.ts deleted file mode 100644 index dc7206be7c..0000000000 --- a/src/common/utils/tentative-stringify-json.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { pipeline } from "@ogre-tools/fp"; -import { defaultTo } from "lodash/fp"; -import { withErrorSuppression } from "./with-error-suppression/with-error-suppression"; - -export const tentativeStringifyJson = (toBeParsed: any) => pipeline( - toBeParsed, - withErrorSuppression(JSON.stringify), - defaultTo(toBeParsed), -); - - diff --git a/src/common/utils/type-narrowing.ts b/src/common/utils/type-narrowing.ts index eb5b6996cf..936fb48816 100644 --- a/src/common/utils/type-narrowing.ts +++ b/src/common/utils/type-narrowing.ts @@ -35,6 +35,15 @@ export function hasTypedProperty(val return hasOwnProperty(val, key) && isValid(val[key]); } +/** + * Narrows `val` to include the property `key` with type string + * @param val the value that we are trying to type narrow + * @param key The key to test if it is present on the object (must be a literal for tsc to do any meaningful typing) + */ +export function hasStringProperty(val: S, key: K): val is (S & { [key in K]: string }) { + return hasOwnProperty(val, key) && isString(val[key]); +} + /** * Narrows `val` to include the property `key` with type `V | undefined` or doesn't contain it * @param val the value that we are trying to type narrow @@ -103,7 +112,7 @@ export function isBoolean(val: unknown): val is boolean { * checks if val is of type object and isn't null * @param val the value to be checked */ -export function isObject(val: unknown): val is object { +export function isObject(val: unknown): val is Record { return typeof val === "object" && val !== null; } diff --git a/src/common/utils/with-error-logging/with-error-logging.test.ts b/src/common/utils/with-error-logging/with-error-logging.test.ts index b14b7278e9..b1140d4e54 100644 --- a/src/common/utils/with-error-logging/with-error-logging.test.ts +++ b/src/common/utils/with-error-logging/with-error-logging.test.ts @@ -4,28 +4,25 @@ */ import { getDiForUnitTesting } from "../../../main/getDiForUnitTesting"; -import loggerInjectable from "../../logger.injectable"; -import type { Logger } from "../../logger"; import withErrorLoggingInjectable from "./with-error-logging.injectable"; import { pipeline } from "@ogre-tools/fp"; import type { AsyncFnMock } from "@async-fn/jest"; import asyncFn from "@async-fn/jest"; import { getPromiseStatus } from "../../test-utils/get-promise-status"; +import logErrorInjectable from "../../log-error.injectable"; describe("with-error-logging", () => { describe("given decorated sync function", () => { - let loggerStub: Logger; let toBeDecorated: jest.Mock; let decorated: (a: string, b: string) => number | undefined; + let logErrorMock: jest.Mock; beforeEach(() => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); - loggerStub = { - error: jest.fn(), - } as unknown as Logger; + logErrorMock = jest.fn(); - di.override(loggerInjectable, () => loggerStub); + di.override(logErrorInjectable, () => logErrorMock); const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); @@ -52,7 +49,7 @@ describe("with-error-logging", () => { }); it("does not log error", () => { - expect(loggerStub.error).not.toHaveBeenCalled(); + expect(logErrorMock).not.toHaveBeenCalled(); }); it("returns the value", () => { @@ -75,7 +72,7 @@ describe("with-error-logging", () => { }); it("does not log error", () => { - expect(loggerStub.error).not.toHaveBeenCalled(); + expect(logErrorMock).not.toHaveBeenCalled(); }); it("returns nothing", () => { @@ -104,7 +101,7 @@ describe("with-error-logging", () => { }); it("logs the error", () => { - expect(loggerStub.error).toHaveBeenCalledWith("some-error-message-for-some-error", error); + expect(logErrorMock).toHaveBeenCalledWith("some-error-message-for-some-error", error); }); it("throws", () => { @@ -114,18 +111,16 @@ describe("with-error-logging", () => { }); describe("given decorated async function", () => { - let loggerStub: Logger; let decorated: (a: string, b: string) => Promise; let toBeDecorated: AsyncFnMock; + let logErrorMock: jest.Mock; beforeEach(() => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); - loggerStub = { - error: jest.fn(), - } as unknown as Logger; + logErrorMock = jest.fn(); - di.override(loggerInjectable, () => loggerStub); + di.override(logErrorInjectable, () => logErrorMock); const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); @@ -153,7 +148,7 @@ describe("with-error-logging", () => { }); it("does not log error yet", () => { - expect(loggerStub.error).not.toHaveBeenCalled(); + expect(logErrorMock).not.toHaveBeenCalled(); }); it("does not resolve yet", async () => { @@ -176,7 +171,7 @@ describe("with-error-logging", () => { error = e; } - expect(loggerStub.error).toHaveBeenCalledWith("some-error-message-for-some-error", error); + expect(logErrorMock).toHaveBeenCalledWith("some-error-message-for-some-error", error); }); it("rejects", () => { @@ -198,7 +193,7 @@ describe("with-error-logging", () => { }); it("logs the rejection", () => { - expect(loggerStub.error).toHaveBeenCalledWith( + expect(logErrorMock).toHaveBeenCalledWith( "some-error-message-for-some-rejection", error, ); @@ -215,7 +210,7 @@ describe("with-error-logging", () => { }); it("does not log error", () => { - expect(loggerStub.error).not.toHaveBeenCalled(); + expect(logErrorMock).not.toHaveBeenCalled(); }); it("resolves with the value", async () => { @@ -231,7 +226,7 @@ describe("with-error-logging", () => { }); it("does not log error", () => { - expect(loggerStub.error).not.toHaveBeenCalled(); + expect(logErrorMock).not.toHaveBeenCalled(); }); it("resolves without value", async () => { diff --git a/src/common/utils/with-orphan-promise/with-orphan-promise.test.ts b/src/common/utils/with-orphan-promise/with-orphan-promise.test.ts index cea88b2352..51ebc18e13 100644 --- a/src/common/utils/with-orphan-promise/with-orphan-promise.test.ts +++ b/src/common/utils/with-orphan-promise/with-orphan-promise.test.ts @@ -5,21 +5,20 @@ import type { AsyncFnMock } from "@async-fn/jest"; import asyncFn from "@async-fn/jest"; import { getDiForUnitTesting } from "../../../main/getDiForUnitTesting"; -import loggerInjectable from "../../logger.injectable"; -import type { Logger } from "../../logger"; import withOrphanPromiseInjectable from "./with-orphan-promise.injectable"; +import logErrorInjectable from "../../log-error.injectable"; describe("with orphan promise, when called", () => { let toBeDecorated: AsyncFnMock<(arg1: string, arg2: string) => Promise>; let actual: void; - let loggerStub: Logger; + let logErrorMock: jest.Mock; beforeEach(() => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); - loggerStub = { error: jest.fn() } as unknown as Logger; + logErrorMock = jest.fn(); - di.override(loggerInjectable, () => loggerStub); + di.override(logErrorInjectable, () => logErrorMock); const withOrphanPromise = di.inject(withOrphanPromiseInjectable); @@ -49,7 +48,7 @@ describe("with orphan promise, when called", () => { }); it("logs the rejection", () => { - expect(loggerStub.error).toHaveBeenCalledWith("Orphan promise rejection encountered", "some-error"); + expect(logErrorMock).toHaveBeenCalledWith("Orphan promise rejection encountered", "some-error"); }); it("nothing else happens", () => { diff --git a/src/common/vars.ts b/src/common/vars.ts index be8b472a5d..fe568b5b86 100644 --- a/src/common/vars.ts +++ b/src/common/vars.ts @@ -4,10 +4,7 @@ */ // App's common configuration for any process (main, renderer, build pipeline, etc.) -import path from "path"; -import packageInfo from "../../package.json"; import type { ThemeId } from "../renderer/themes/store"; -import { lazyInitialized } from "./utils/lazy-initialized"; /** * @deprecated Switch to using isMacInjectable @@ -24,8 +21,10 @@ export const isWindows = process.platform === "win32"; */ export const isLinux = process.platform === "linux"; +/** + * @deprecated switch to using `isDebuggingInjectable` + */ export const isDebugging = ["true", "1", "yes", "y", "on"].includes((process.env.DEBUG ?? "").toLowerCase()); -export const isSnap = !!process.env.SNAP; /** * @deprecated Switch to using isTestEnvInjectable @@ -42,85 +41,11 @@ export const isProduction = process.env.NODE_ENV === "production"; */ export const isDevelopment = !isTestEnv && !isProduction; -export const productName = packageInfo.productName; - -/** - * @deprecated Switch to using appNameInjectable - */ -export const appName = `${packageInfo.productName}${isDevelopment ? "Dev" : ""}`; - export const publicPath = "/build/" as string; export const defaultThemeId: ThemeId = "lens-dark"; export const defaultFontSize = 12; export const defaultTerminalFontFamily = "RobotoMono"; export const defaultEditorFontFamily = "RobotoMono"; -/** - * @deprecated use `di.inject(normalizedPlatformInjectable)` instead - */ -export const normalizedPlatform = (() => { - switch (process.platform) { - case "darwin": - return "darwin"; - case "linux": - return "linux"; - case "win32": - return "windows"; - default: - throw new Error(`platform=${process.platform} is unsupported`); - } -})(); -/** - * @deprecated use `di.inject(bundledBinariesNormalizedArchInjectable)` instead - */ -export const normalizedArch = (() => { - switch (process.arch) { - case "arm64": - return "arm64"; - case "x64": - case "amd64": - return "x64"; - case "386": - case "x32": - case "ia32": - return "ia32"; - default: - throw new Error(`arch=${process.arch} is unsupported`); - } -})(); - -export function getBinaryName(name: string, { forPlatform = normalizedPlatform } = {}): string { - if (forPlatform === "windows") { - return `${name}.exe`; - } - - return name; -} - -const resourcesDir = lazyInitialized(() => ( - isProduction - ? process.resourcesPath - : path.join(process.cwd(), "binaries", "client", normalizedPlatform) -)); - -/** - * @deprecated for being explicit side effect. - */ -export const baseBinariesDir = lazyInitialized(() => path.join(resourcesDir.get(), normalizedArch)); - -/** - * @deprecated for being explicit side effect. - */ -export const kubeAuthProxyBinaryName = getBinaryName("lens-k8s-proxy"); - -/** - * @deprecated for being explicit side effect. - */ -export const helmBinaryName = getBinaryName("helm"); - -/** - * @deprecated for being explicit side effect. - */ -export const helmBinaryPath = lazyInitialized(() => path.join(baseBinariesDir.get(), helmBinaryName)); // Apis export const apiPrefix = "/api"; // local router apis @@ -129,7 +54,7 @@ export const apiKubePrefix = "/api-kube"; // k8s cluster apis // Links export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues" as string; export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/zt-wcl8jq3k-68R5Wcmk1o95MLBE5igUDQ" as string; -export const supportUrl = "https://docs.k8slens.dev/latest/support/" as string; +export const supportUrl = "https://docs.k8slens.dev/support/" as string; export const lensWebsiteWeblinkId = "lens-website-link"; export const lensDocumentationWeblinkId = "lens-documentation-link"; @@ -138,7 +63,4 @@ export const lensTwitterWeblinkId = "lens-twitter-link"; export const lensBlogWeblinkId = "lens-blog-link"; export const kubernetesDocumentationWeblinkId = "kubernetes-documentation-link"; -export const docsUrl = "https://docs.k8slens.dev/main" as string; - -export const sentryDsn = packageInfo.config?.sentryDsn ?? ""; -export const contentSecurityPolicy = packageInfo.config?.contentSecurityPolicy ?? ""; +export const docsUrl = "https://docs.k8slens.dev" as string; diff --git a/src/main/app-paths/app-name/app-name.injectable.ts b/src/common/vars/app-name.injectable.ts similarity index 83% rename from src/main/app-paths/app-name/app-name.injectable.ts rename to src/common/vars/app-name.injectable.ts index 0a1db468d8..4d6d87421c 100644 --- a/src/main/app-paths/app-name/app-name.injectable.ts +++ b/src/common/vars/app-name.injectable.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import isDevelopmentInjectable from "../../../common/vars/is-development.injectable"; +import isDevelopmentInjectable from "./is-development.injectable"; import productNameInjectable from "./product-name.injectable"; const appNameInjectable = getInjectable({ @@ -15,8 +15,6 @@ const appNameInjectable = getInjectable({ return `${productName}${isDevelopment ? "Dev" : ""}`; }, - - causesSideEffects: true, }); export default appNameInjectable; diff --git a/src/common/vars/app-semantic-version.injectable.ts b/src/common/vars/app-semantic-version.injectable.ts deleted file mode 100644 index ae68ea828d..0000000000 --- a/src/common/vars/app-semantic-version.injectable.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * 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 { SemVer } from "semver"; -import appVersionInjectable from "./app-version.injectable"; - -const appSemanticVersionInjectable = getInjectable({ - id: "app-semantic-version", - instantiate: (di) => new SemVer(di.inject(appVersionInjectable)), -}); - -export default appSemanticVersionInjectable; diff --git a/src/common/vars/app-version.injectable.ts b/src/common/vars/app-version.injectable.ts deleted file mode 100644 index d7647f8318..0000000000 --- a/src/common/vars/app-version.injectable.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 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 packageJsonInjectable from "./package-json.injectable"; - -const appVersionInjectable = getInjectable({ - id: "app-version", - instantiate: (di) => di.inject(packageJsonInjectable).version, -}); - -export default appVersionInjectable; diff --git a/src/common/vars/application-copyright.injectable.ts b/src/common/vars/application-copyright.injectable.ts new file mode 100644 index 0000000000..cdac64855c --- /dev/null +++ b/src/common/vars/application-copyright.injectable.ts @@ -0,0 +1,13 @@ +/** + * 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 applicationInformationInjectable from "./application-information.injectable"; + +const applicationCopyrightInjectable = getInjectable({ + id: "application-copyright", + instantiate: (di) => di.inject(applicationInformationInjectable).copyright, +}); + +export default applicationCopyrightInjectable; diff --git a/src/common/vars/application-description.injectable.ts b/src/common/vars/application-description.injectable.ts new file mode 100644 index 0000000000..d6c4c9f79b --- /dev/null +++ b/src/common/vars/application-description.injectable.ts @@ -0,0 +1,13 @@ +/** + * 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 applicationInformationInjectable from "./application-information.injectable"; + +const applicationDescriptionInjectable = getInjectable({ + id: "application-description", + instantiate: (di) => di.inject(applicationInformationInjectable).description, +}); + +export default applicationDescriptionInjectable; diff --git a/src/common/vars/application-information.global-override-for-injectable.ts b/src/common/vars/application-information.global-override-for-injectable.ts new file mode 100644 index 0000000000..232a189ce1 --- /dev/null +++ b/src/common/vars/application-information.global-override-for-injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import applicationInformationInjectable from "./application-information.injectable"; + +export default getGlobalOverride(applicationInformationInjectable, () => ({ + name: "some-product-name", + productName: "some-product-name", + version: "6.0.0", + build: {}, + config: { + k8sProxyVersion: "0.2.1", + bundledKubectlVersion: "1.23.3", + bundledHelmVersion: "3.7.2", + sentryDsn: "", + contentSecurityPolicy: "script-src 'unsafe-eval' 'self'; frame-src http://*.localhost:*/; img-src * data:", + welcomeRoute: "/welcome", + }, + copyright: "some-copyright-information", + description: "some-descriptive-text", +})); diff --git a/src/common/vars/application-information.injectable.ts b/src/common/vars/application-information.injectable.ts new file mode 100644 index 0000000000..56dfcf9d7e --- /dev/null +++ b/src/common/vars/application-information.injectable.ts @@ -0,0 +1,22 @@ +/** + * 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 packageJson from "../../../package.json"; + +export type ApplicationInformation = Pick & { + build: Partial & { publish?: unknown[] }; +}; + +const applicationInformationInjectable = getInjectable({ + id: "application-information", + instantiate: (): ApplicationInformation => { + const { version, config, productName, build, copyright, description, name } = packageJson; + + return { version, config, productName, build, copyright, description, name }; + }, + causesSideEffects: true, +}); + +export default applicationInformationInjectable; diff --git a/src/common/vars/base-bundled-binaries-dir.injectable.ts b/src/common/vars/base-bundled-binaries-dir.injectable.ts index f0d78482c8..968ccc5c5b 100644 --- a/src/common/vars/base-bundled-binaries-dir.injectable.ts +++ b/src/common/vars/base-bundled-binaries-dir.injectable.ts @@ -4,17 +4,17 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import bundledResourcesDirectoryInjectable from "./bundled-resources-dir.injectable"; -import getAbsolutePathInjectable from "../path/get-absolute-path.injectable"; import normalizedPlatformArchitectureInjectable from "./normalized-platform-architecture.injectable"; +import joinPathsInjectable from "../path/join-paths.injectable"; const baseBundledBinariesDirectoryInjectable = getInjectable({ id: "base-bundled-binaries-directory", instantiate: (di) => { const bundledResourcesDirectory = di.inject(bundledResourcesDirectoryInjectable); const normalizedPlatformArchitecture = di.inject(normalizedPlatformArchitectureInjectable); - const getAbsolutePath = di.inject(getAbsolutePathInjectable); + const joinPaths = di.inject(joinPathsInjectable); - return getAbsolutePath( + return joinPaths( bundledResourcesDirectory, normalizedPlatformArchitecture, ); diff --git a/src/common/vars/build-semantic-version.injectable.ts b/src/common/vars/build-semantic-version.injectable.ts new file mode 100644 index 0000000000..2a49327480 --- /dev/null +++ b/src/common/vars/build-semantic-version.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import { SemVer } from "semver"; +import type { InitializableState } from "../initializable-state/create"; +import { createInitializableState } from "../initializable-state/create"; +import type { RequestChannel } from "../utils/channel/request-channel-listener-injection-token"; + +export const buildVersionInjectionToken = getInjectionToken>({ + id: "build-version-token", +}); + +export const buildVersionChannel: RequestChannel = { + id: "build-version", +}; + +const buildSemanticVersionInjectable = createInitializableState({ + id: "build-semantic-version", + init: (di) => { + const buildVersion = di.inject(buildVersionInjectionToken); + + return new SemVer(buildVersion.get()); + }, +}); + +export default buildSemanticVersionInjectable; + diff --git a/src/common/vars/bundled-kubectl-version.injectable.ts b/src/common/vars/bundled-kubectl-version.injectable.ts new file mode 100644 index 0000000000..9542a79834 --- /dev/null +++ b/src/common/vars/bundled-kubectl-version.injectable.ts @@ -0,0 +1,13 @@ +/** + * 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 applicationInformationInjectable from "./application-information.injectable"; + +const bundledKubectlVersionInjectable = getInjectable({ + id: "bundled-kubectl-version", + instantiate: (di) => di.inject(applicationInformationInjectable).config.bundledKubectlVersion, +}); + +export default bundledKubectlVersionInjectable; diff --git a/src/common/vars/bundled-resources-dir.injectable.ts b/src/common/vars/bundled-resources-dir.injectable.ts index e20a638384..2058b1d5d3 100644 --- a/src/common/vars/bundled-resources-dir.injectable.ts +++ b/src/common/vars/bundled-resources-dir.injectable.ts @@ -5,20 +5,20 @@ import { getInjectable } from "@ogre-tools/injectable"; import isProductionInjectable from "./is-production.injectable"; import normalizedPlatformInjectable from "./normalized-platform.injectable"; -import getAbsolutePathInjectable from "../path/get-absolute-path.injectable"; import lensResourcesDirInjectable from "./lens-resources-dir.injectable"; +import joinPathsInjectable from "../path/join-paths.injectable"; const bundledResourcesDirectoryInjectable = getInjectable({ id: "bundled-resources-directory", instantiate: (di) => { const isProduction = di.inject(isProductionInjectable); const normalizedPlatform = di.inject(normalizedPlatformInjectable); - const getAbsolutePath = di.inject(getAbsolutePathInjectable); + const joinPaths = di.inject(joinPathsInjectable); const lensResourcesDir = di.inject(lensResourcesDirInjectable); return isProduction ? lensResourcesDir - : getAbsolutePath(lensResourcesDir, "binaries", "client", normalizedPlatform); + : joinPaths(lensResourcesDir, "binaries", "client", normalizedPlatform); }, }); diff --git a/src/common/vars/content-security-policy.injectable.ts b/src/common/vars/content-security-policy.injectable.ts new file mode 100644 index 0000000000..b6e1e0eb30 --- /dev/null +++ b/src/common/vars/content-security-policy.injectable.ts @@ -0,0 +1,13 @@ +/** + * 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 applicationInformationInjectable from "./application-information.injectable"; + +const contentSecurityPolicyInjectable = getInjectable({ + id: "content-security-policy", + instantiate: (di) => di.inject(applicationInformationInjectable).config.contentSecurityPolicy, +}); + +export default contentSecurityPolicyInjectable; diff --git a/src/common/vars/extension-api-version.injectable.ts b/src/common/vars/extension-api-version.injectable.ts new file mode 100644 index 0000000000..4f7f4d9930 --- /dev/null +++ b/src/common/vars/extension-api-version.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 { SemVer } from "semver"; +import applicationInformationInjectable from "./application-information.injectable"; + +const extensionApiVersionInjectable = getInjectable({ + id: "extension-api-version", + instantiate: (di) => { + const { major, minor, patch } = new SemVer(di.inject(applicationInformationInjectable).version); + + return `${major}.${minor}.${patch}`; + }, +}); + +export default extensionApiVersionInjectable; diff --git a/src/common/app-event-bus/app-event-bus.global-override-for-injectable.ts b/src/common/vars/is-debugging.global-override-for-injectable.ts similarity index 62% rename from src/common/app-event-bus/app-event-bus.global-override-for-injectable.ts rename to src/common/vars/is-debugging.global-override-for-injectable.ts index 2cdd21c919..7aa500ff2e 100644 --- a/src/common/app-event-bus/app-event-bus.global-override-for-injectable.ts +++ b/src/common/vars/is-debugging.global-override-for-injectable.ts @@ -2,7 +2,8 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getGlobalOverride } from "../test-utils/get-global-override"; -import emitEventInjectable from "./emit-event.injectable"; -export default getGlobalOverride(emitEventInjectable, () => () => {}); +import { getGlobalOverride } from "../test-utils/get-global-override"; +import isDebuggingInjectable from "./is-debugging.injectable"; + +export default getGlobalOverride(isDebuggingInjectable, () => false); diff --git a/src/common/vars/is-debugging.injectable.ts b/src/common/vars/is-debugging.injectable.ts new file mode 100644 index 0000000000..079086e628 --- /dev/null +++ b/src/common/vars/is-debugging.injectable.ts @@ -0,0 +1,13 @@ +/** + * 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"; + +const isDebuggingInjectable = getInjectable({ + id: "is-debugging", + instantiate: () => ["true", "1", "yes", "y", "on"].includes((process.env.DEBUG ?? "").toLowerCase()), + causesSideEffects: true, +}); + +export default isDebuggingInjectable; diff --git a/src/common/vars/is-snap-package.global-override-for-injectable.ts b/src/common/vars/is-snap-package.global-override-for-injectable.ts new file mode 100644 index 0000000000..cb3ff0a6e9 --- /dev/null +++ b/src/common/vars/is-snap-package.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import isSnapPackageInjectable from "./is-snap-package.injectable"; + +export default getGlobalOverride(isSnapPackageInjectable, () => false); diff --git a/src/common/vars/is-snap-package.injectable.ts b/src/common/vars/is-snap-package.injectable.ts new file mode 100644 index 0000000000..a2c545870b --- /dev/null +++ b/src/common/vars/is-snap-package.injectable.ts @@ -0,0 +1,13 @@ +/** + * 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"; + +const isSnapPackageInjectable = getInjectable({ + id: "is-snap", + instantiate: () => Boolean(process.env.SNAP), + causesSideEffects: true, +}); + +export default isSnapPackageInjectable; diff --git a/src/common/vars/lens-resources-dir.injectable.ts b/src/common/vars/lens-resources-dir.injectable.ts index c454afb005..9aebba19b3 100644 --- a/src/common/vars/lens-resources-dir.injectable.ts +++ b/src/common/vars/lens-resources-dir.injectable.ts @@ -11,9 +11,9 @@ const lensResourcesDirInjectable = getInjectable({ instantiate: (di) => { const isProduction = di.inject(isProductionInjectable); - return !isProduction - ? process.cwd() - : process.resourcesPath; + return isProduction + ? process.resourcesPath + : process.cwd(); }, causesSideEffects: true, diff --git a/src/common/vars/normalized-platform.injectable.ts b/src/common/vars/normalized-platform.injectable.ts index cb34f1d2fa..ee1bf7fb74 100644 --- a/src/common/vars/normalized-platform.injectable.ts +++ b/src/common/vars/normalized-platform.injectable.ts @@ -5,10 +5,12 @@ import { getInjectable } from "@ogre-tools/injectable"; import platformInjectable from "./platform.injectable"; +export type NormalizedPlatform = "darwin" | "linux" | "windows"; + const normalizedPlatformInjectable = getInjectable({ id: "normalized-platform", - instantiate: (di) => { + instantiate: (di): NormalizedPlatform => { const platform = di.inject(platformInjectable); switch (platform) { diff --git a/src/common/vars/platform.injectable.ts b/src/common/vars/platform.injectable.ts index 11939a7f06..f2c681d657 100644 --- a/src/common/vars/platform.injectable.ts +++ b/src/common/vars/platform.injectable.ts @@ -4,9 +4,12 @@ */ import { getInjectable } from "@ogre-tools/injectable"; +// Todo: OCP by creating distinct injectables for platforms. +export const allPlatforms = ["win32", "darwin", "linux"] as const; + const platformInjectable = getInjectable({ id: "platform", - instantiate: () => process.platform, + instantiate: () => process.platform as typeof allPlatforms[number], causesSideEffects: true, }); diff --git a/src/main/app-paths/app-name/product-name.injectable.ts b/src/common/vars/product-name.injectable.ts similarity index 65% rename from src/main/app-paths/app-name/product-name.injectable.ts rename to src/common/vars/product-name.injectable.ts index 8c5c53bfba..910c6afa48 100644 --- a/src/main/app-paths/app-name/product-name.injectable.ts +++ b/src/common/vars/product-name.injectable.ts @@ -3,12 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import packageInfo from "../../../../package.json"; +import applicationInformationInjectable from "./application-information.injectable"; const productNameInjectable = getInjectable({ id: "product-name", - instantiate: () => packageInfo.productName, - causesSideEffects: true, + instantiate: (di) => di.inject(applicationInformationInjectable).productName, }); export default productNameInjectable; diff --git a/src/common/vars/release-channel.injectable.ts b/src/common/vars/release-channel.injectable.ts new file mode 100644 index 0000000000..6554fbc0ac --- /dev/null +++ b/src/common/vars/release-channel.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { createInitializableState } from "../initializable-state/create"; +import buildSemanticVersionInjectable from "./build-semantic-version.injectable"; +import type { ReleaseChannel } from "../../features/application-update/common/update-channels"; + +const releaseChannelInjectable = createInitializableState({ + id: "release-channel", + init: (di): ReleaseChannel => { + const buildSemanticVersion = di.inject(buildSemanticVersionInjectable); + const currentReleaseChannel = buildSemanticVersion.get().prerelease[0]; + + switch (currentReleaseChannel) { + case "latest": + case "beta": + case "alpha": + return currentReleaseChannel; + default: + return "latest"; + } + }, +}); + +export default releaseChannelInjectable; diff --git a/src/common/vars/sentry-dsn-url.injectable.ts b/src/common/vars/sentry-dsn-url.injectable.ts new file mode 100644 index 0000000000..e33c2fd0de --- /dev/null +++ b/src/common/vars/sentry-dsn-url.injectable.ts @@ -0,0 +1,13 @@ +/** + * 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 applicationInformationInjectable from "./application-information.injectable"; + +const sentryDataSourceNameInjectable = getInjectable({ + id: "sentry-data-source-name", + instantiate: (di) => di.inject(applicationInformationInjectable).config.sentryDsn, +}); + +export default sentryDataSourceNameInjectable; diff --git a/src/common/vars/static-files-directory.injectable.ts b/src/common/vars/static-files-directory.injectable.ts index c881f12b97..e65e854e9c 100644 --- a/src/common/vars/static-files-directory.injectable.ts +++ b/src/common/vars/static-files-directory.injectable.ts @@ -3,17 +3,17 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import getAbsolutePathInjectable from "../path/get-absolute-path.injectable"; +import joinPathsInjectable from "../path/join-paths.injectable"; import lensResourcesDirInjectable from "./lens-resources-dir.injectable"; const staticFilesDirectoryInjectable = getInjectable({ id: "static-files-directory", instantiate: (di) => { - const getAbsolutePath = di.inject(getAbsolutePathInjectable); + const joinPaths = di.inject(joinPathsInjectable); const lensResourcesDir = di.inject(lensResourcesDirInjectable); - return getAbsolutePath(lensResourcesDir, "static"); + return joinPaths(lensResourcesDir, "static"); }, }); diff --git a/src/common/vars/store-migration-version.injectable.ts b/src/common/vars/store-migration-version.injectable.ts new file mode 100644 index 0000000000..79ab1578a3 --- /dev/null +++ b/src/common/vars/store-migration-version.injectable.ts @@ -0,0 +1,13 @@ +/** + * 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 applicationInformationInjectable from "./application-information.injectable"; + +const storeMigrationVersionInjectable = getInjectable({ + id: "store-migration-version", + instantiate: (di) => di.inject(applicationInformationInjectable).version, +}); + +export default storeMigrationVersionInjectable; diff --git a/src/extensions/__tests__/is-compatible-extension.test.ts b/src/extensions/__tests__/is-compatible-extension.test.ts index d581722139..f2ca144681 100644 --- a/src/extensions/__tests__/is-compatible-extension.test.ts +++ b/src/extensions/__tests__/is-compatible-extension.test.ts @@ -3,65 +3,53 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import assert from "assert"; -import semver from "semver"; import { isCompatibleExtension } from "../extension-discovery/is-compatible-extension/is-compatible-extension"; import type { LensExtensionManifest } from "../lens-extension"; describe("Extension/App versions compatibility checks", () => { it("is compatible with exact version matching", () => { - expect(isCompatible({ extLensEngineVersion: "5.5.0", appVersion: "5.5.0" })).toBeTruthy(); + expect(isCompatible({ extLensEngineVersion: "5.5.0", extensionApiVersion: "5.5.0" })).toBeTruthy(); }); it("is compatible with upper %PATCH versions of base app", () => { - expect(isCompatible({ extLensEngineVersion: "5.5.0", appVersion: "5.5.5" })).toBeTruthy(); + expect(isCompatible({ extLensEngineVersion: "5.5.0", extensionApiVersion: "5.5.5" })).toBeTruthy(); }); it("is compatible with higher %MINOR version of base app", () => { - expect(isCompatible({ extLensEngineVersion: "5.5.0", appVersion: "5.6.0" })).toBeTruthy(); + expect(isCompatible({ extLensEngineVersion: "5.5.0", extensionApiVersion: "5.6.0" })).toBeTruthy(); }); it("is not compatible with higher %MAJOR version of base app", () => { - expect(isCompatible({ extLensEngineVersion: "5.6.0", appVersion: "6.0.0" })).toBeFalsy(); // extension for lens@5 not compatible with lens@6 - expect(isCompatible({ extLensEngineVersion: "6.0.0", appVersion: "5.6.0" })).toBeFalsy(); - }); - - it("is compatible with lensEngine with prerelease", () => { - expect(isCompatible({ - extLensEngineVersion: "^5.4.0-alpha.0", - appVersion: "5.5.0-alpha.0", - })).toBeTruthy(); + expect(isCompatible({ extLensEngineVersion: "5.6.0", extensionApiVersion: "6.0.0" })).toBeFalsy(); // extension for lens@5 not compatible with lens@6 + expect(isCompatible({ extLensEngineVersion: "6.0.0", extensionApiVersion: "5.6.0" })).toBeFalsy(); }); it("supports short version format for manifest.engines.lens", () => { - expect(isCompatible({ extLensEngineVersion: "5.5", appVersion: "5.5.1" })).toBeTruthy(); + expect(isCompatible({ extLensEngineVersion: "5.5", extensionApiVersion: "5.5.1" })).toBeTruthy(); }); it("throws for incorrect or not supported version format", () => { expect(() => isCompatible({ extLensEngineVersion: ">=2.0", - appVersion: "2.0", + extensionApiVersion: "2.0", })).toThrow(/Invalid format/i); expect(() => isCompatible({ extLensEngineVersion: "~2.0", - appVersion: "2.0", + extensionApiVersion: "2.0", })).toThrow(/Invalid format/i); expect(() => isCompatible({ extLensEngineVersion: "*", - appVersion: "1.0", + extensionApiVersion: "1.0", })).toThrow(/Invalid format/i); }); }); -function isCompatible({ extLensEngineVersion = "^1.0", appVersion = "1.0" } = {}): boolean { - const appSemVer = semver.coerce(appVersion); +function isCompatible({ extLensEngineVersion = "^1.0", extensionApiVersion = "1.0" } = {}): boolean { const extensionManifestMock = getExtensionManifestMock(extLensEngineVersion); - assert(appSemVer); - - return isCompatibleExtension({ appSemVer })(extensionManifestMock); + return isCompatibleExtension({ extensionApiVersion })(extensionManifestMock); } function getExtensionManifestMock(lensEngine = "1.0"): LensExtensionManifest { diff --git a/src/extensions/common-api/app.ts b/src/extensions/common-api/app.ts index 6be9a193d1..92ecd19ef9 100644 --- a/src/extensions/common-api/app.ts +++ b/src/extensions/common-api/app.ts @@ -3,14 +3,65 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getAppVersion } from "../../common/utils"; +import appNameInjectable from "../../common/vars/app-name.injectable"; +import isLinuxInjectable from "../../common/vars/is-linux.injectable"; +import isMacInjectable from "../../common/vars/is-mac.injectable"; +import isSnapPackageInjectable from "../../common/vars/is-snap-package.injectable"; +import isWindowsInjectable from "../../common/vars/is-windows.injectable"; import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api"; +import { getLegacyGlobalDiForExtensionApi } from "../as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; import getEnabledExtensionsInjectable from "./get-enabled-extensions/get-enabled-extensions.injectable"; -import * as Preferences from "./user-preferences"; +import type { UserPreferenceExtensionItems } from "./user-preferences"; +import { Preferences } from "./user-preferences"; +import { slackUrl, issuesTrackerUrl } from "../../common/vars"; +import { buildVersionInjectionToken } from "../../common/vars/build-semantic-version.injectable"; -export const version = getAppVersion(); -export { isSnap, isWindows, isMac, isLinux, appName, slackUrl, issuesTrackerUrl } from "../../common/vars"; +export interface AppExtensionItems { + readonly Preferences: UserPreferenceExtensionItems; + readonly version: string; + readonly appName: string; + readonly slackUrl: string; + readonly issuesTrackerUrl: string; + readonly isSnap: boolean; + readonly isWindows: boolean; + readonly isMac: boolean; + readonly isLinux: boolean; + getEnabledExtensions: () => string[]; +} -export const getEnabledExtensions = asLegacyGlobalFunctionForExtensionApi(getEnabledExtensionsInjectable); +export const App: AppExtensionItems = { + Preferences, + getEnabledExtensions: asLegacyGlobalFunctionForExtensionApi(getEnabledExtensionsInjectable), + get version() { + const di = getLegacyGlobalDiForExtensionApi(); -export { Preferences }; + return di.inject(buildVersionInjectionToken).get(); + }, + get appName() { + const di = getLegacyGlobalDiForExtensionApi(); + + return di.inject(appNameInjectable); + }, + get isSnap() { + const di = getLegacyGlobalDiForExtensionApi(); + + return di.inject(isSnapPackageInjectable); + }, + get isWindows() { + const di = getLegacyGlobalDiForExtensionApi(); + + return di.inject(isWindowsInjectable); + }, + get isMac() { + const di = getLegacyGlobalDiForExtensionApi(); + + return di.inject(isMacInjectable); + }, + get isLinux() { + const di = getLegacyGlobalDiForExtensionApi(); + + return di.inject(isLinuxInjectable); + }, + slackUrl, + issuesTrackerUrl, +}; diff --git a/src/extensions/common-api/event-bus.ts b/src/extensions/common-api/event-bus.ts index c6d2a1a34b..d95e3f49d4 100644 --- a/src/extensions/common-api/event-bus.ts +++ b/src/extensions/common-api/event-bus.ts @@ -3,5 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -export { appEventBus } from "../../common/app-event-bus/event-bus"; +import appEventBusInjectable from "../../common/app-event-bus/app-event-bus.injectable"; +import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; + export type { AppEvent } from "../../common/app-event-bus/event-bus"; + +export const appEventBus = asLegacyGlobalForExtensionApi(appEventBusInjectable); diff --git a/src/extensions/common-api/index.ts b/src/extensions/common-api/index.ts index 0d07220d1d..6e4b39b1b7 100644 --- a/src/extensions/common-api/index.ts +++ b/src/extensions/common-api/index.ts @@ -4,10 +4,10 @@ */ // APIs -import * as App from "./app"; +import { App } from "./app"; import * as EventBus from "./event-bus"; import * as Store from "./stores"; -import * as Util from "./utils"; +import { Util } from "./utils"; import * as Catalog from "./catalog"; import * as Types from "./types"; import * as Proxy from "./proxy"; diff --git a/src/extensions/common-api/k8s-api.ts b/src/extensions/common-api/k8s-api.ts index b09f977b1d..e5c7013bc7 100644 --- a/src/extensions/common-api/k8s-api.ts +++ b/src/extensions/common-api/k8s-api.ts @@ -7,20 +7,53 @@ // It is here to consolidate the common parts which are exported to `Main` // and to `Renderer` -export { ResourceStack } from "../../common/k8s/resource-stack"; import apiManagerInjectable from "../../common/k8s-api/api-manager/manager.injectable"; +import createKubeApiForClusterInjectable from "../../common/k8s-api/create-kube-api-for-cluster.injectable"; +import createKubeApiForRemoteClusterInjectable from "../../common/k8s-api/create-kube-api-for-remote-cluster.injectable"; +import createResourceStackInjectable from "../../common/k8s/create-resource-stack.injectable"; +import type { ResourceApplyingStack } from "../../common/k8s/resource-stack"; +import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api"; import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import type { KubernetesCluster } from "./catalog"; export const apiManager = asLegacyGlobalForExtensionApi(apiManagerInjectable); +export const forCluster = asLegacyGlobalFunctionForExtensionApi(createKubeApiForClusterInjectable); +export const forRemoteCluster = asLegacyGlobalFunctionForExtensionApi(createKubeApiForRemoteClusterInjectable); -export { - KubeApi, - forCluster, - forRemoteCluster, - type ILocalKubeApiConfig, - type IRemoteKubeApiConfig, - type IKubeApiCluster, -} from "../../common/k8s-api/kube-api"; +export { KubeApi } from "../../common/k8s-api/kube-api"; + +export const createResourceStack = asLegacyGlobalFunctionForExtensionApi(createResourceStackInjectable); + +/** + * @deprecated Switch to using `Common.createResourceStack` instead + */ +export class ResourceStack implements ResourceApplyingStack { + private readonly impl: ResourceApplyingStack; + + constructor(cluster: KubernetesCluster, name: string) { + this.impl = createResourceStack(cluster, name); + } + + kubectlApplyFolder(folderPath: string, templateContext?: any, extraArgs?: string[] | undefined): Promise { + return this.impl.kubectlApplyFolder(folderPath, templateContext, extraArgs); + } + + kubectlDeleteFolder(folderPath: string, templateContext?: any, extraArgs?: string[] | undefined): Promise { + return this.impl.kubectlDeleteFolder(folderPath, templateContext, extraArgs); + } +} + +/** + * @deprecated This type is unused + */ +export interface IKubeApiCluster { + metadata: { + uid: string; + }; +} + +export type { CreateKubeApiForRemoteClusterConfig as IRemoteKubeApiConfig } from "../../common/k8s-api/create-kube-api-for-remote-cluster.injectable"; +export type { CreateKubeApiForLocalClusterConfig as ILocalKubeApiConfig } from "../../common/k8s-api/create-kube-api-for-cluster.injectable"; export { KubeObject, @@ -35,6 +68,7 @@ export { } from "../../common/k8s-api/kube-object"; export { + KubeJsonApi, type KubeJsonApiData, } from "../../common/k8s-api/kube-json-api"; diff --git a/src/extensions/common-api/registrations.ts b/src/extensions/common-api/registrations.ts index 5f19ec495d..4bfda25170 100644 --- a/src/extensions/common-api/registrations.ts +++ b/src/extensions/common-api/registrations.ts @@ -4,7 +4,7 @@ */ export type { StatusBarRegistration } from "../../renderer/components/status-bar/status-bar-registration"; export type { KubeObjectMenuRegistration, KubeObjectMenuComponents } from "../../renderer/components/kube-object-menu/kube-object-menu-registration"; -export type { AppPreferenceRegistration, AppPreferenceComponents } from "../../renderer/components/+preferences/app-preferences/app-preference-registration"; +export type { AppPreferenceRegistration, AppPreferenceComponents } from "../../features/preferences/renderer/compliance-for-legacy-extension-api/app-preference-registration"; export type { KubeObjectDetailRegistration, KubeObjectDetailComponents } from "../../renderer/components/kube-object-details/kube-object-detail-registration"; export type { KubeObjectStatusRegistration } from "../../renderer/components/kube-object-status-icon/kube-object-status-registration"; export type { PageRegistration, RegisteredPage, PageParams, PageComponentProps, PageComponents, PageTarget } from "../registries/page-registry"; diff --git a/src/extensions/common-api/user-preferences.ts b/src/extensions/common-api/user-preferences.ts index 2c44f6f604..3a0a93793b 100644 --- a/src/extensions/common-api/user-preferences.ts +++ b/src/extensions/common-api/user-preferences.ts @@ -4,10 +4,13 @@ */ import { UserStore } from "../../common/user-store"; - -/** - * Get the configured kubectl binaries path. - */ -export function getKubectlPath(): string | undefined { - return UserStore.getInstance().kubectlBinariesPath; +export interface UserPreferenceExtensionItems { + /** + * Get the configured kubectl binaries path. + */ + getKubectlPath: () => string | undefined; } + +export const Preferences: UserPreferenceExtensionItems = { + getKubectlPath: () => UserStore.getInstance().kubectlBinariesPath, +}; diff --git a/src/extensions/common-api/utils.ts b/src/extensions/common-api/utils.ts index 3dea165238..a6dfdee447 100644 --- a/src/extensions/common-api/utils.ts +++ b/src/extensions/common-api/utils.ts @@ -4,14 +4,34 @@ */ import openLinkInBrowserInjectable from "../../common/utils/open-link-in-browser.injectable"; +import buildVersionInjectable from "../../main/vars/build-version/build-version.injectable"; import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api"; +import { getLegacyGlobalDiForExtensionApi } from "../as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; +import { Singleton } from "../../common/utils"; +import { prevDefault, stopPropagation } from "../../renderer/utils/prevDefault"; +import type { IClassName } from "../../renderer/utils/cssNames"; +import { cssNames } from "../../renderer/utils/cssNames"; -export { Singleton, getAppVersion } from "../../common/utils"; -export { prevDefault, stopPropagation } from "../../renderer/utils/prevDefault"; -export { cssNames } from "../../renderer/utils/cssNames"; +export interface UtilsExtensionItems { + Singleton: typeof Singleton; + prevDefault: (callback: (evt: E) => R) => (evt: E) => R; + stopPropagation: (evt: Event | React.SyntheticEvent) => void; + cssNames: (...classNames: IClassName[]) => string; + openExternal: (url: string) => Promise; + openBrowser: (url: string) => Promise; + getAppVersion: () => string; +} -/** - * @deprecated Use {@link openBrowser} instead - */ -export const openExternal = asLegacyGlobalFunctionForExtensionApi(openLinkInBrowserInjectable); -export const openBrowser = asLegacyGlobalFunctionForExtensionApi(openLinkInBrowserInjectable); +export const Util: UtilsExtensionItems = { + Singleton, + prevDefault, + stopPropagation, + cssNames, + openExternal: asLegacyGlobalFunctionForExtensionApi(openLinkInBrowserInjectable), + openBrowser: asLegacyGlobalFunctionForExtensionApi(openLinkInBrowserInjectable), + getAppVersion: () => { + const di = getLegacyGlobalDiForExtensionApi(); + + return di.inject(buildVersionInjectable).get(); + }, +}; diff --git a/src/extensions/extension-discovery/extension-discovery.injectable.ts b/src/extensions/extension-discovery/extension-discovery.injectable.ts index 48e9b225a0..09d0f3b0ae 100644 --- a/src/extensions/extension-discovery/extension-discovery.injectable.ts +++ b/src/extensions/extension-discovery/extension-discovery.injectable.ts @@ -16,25 +16,50 @@ import readJsonFileInjectable from "../../common/fs/read-json-file.injectable"; import loggerInjectable from "../../common/logger.injectable"; import pathExistsInjectable from "../../common/fs/path-exists.injectable"; import watchInjectable from "../../common/fs/watch/watch.injectable"; +import accessPathInjectable from "../../common/fs/access-path.injectable"; +import copyInjectable from "../../common/fs/copy.injectable"; +import ensureDirInjectable from "../../common/fs/ensure-dir.injectable"; +import isProductionInjectable from "../../common/vars/is-production.injectable"; +import lstatInjectable from "../../common/fs/lstat.injectable"; +import readDirectoryInjectable from "../../common/fs/read-directory.injectable"; +import fileSystemSeparatorInjectable from "../../common/path/separator.injectable"; +import getBasenameOfPathInjectable from "../../common/path/get-basename.injectable"; +import getDirnameOfPathInjectable from "../../common/path/get-dirname.injectable"; +import getRelativePathInjectable from "../../common/path/get-relative-path.injectable"; +import joinPathsInjectable from "../../common/path/join-paths.injectable"; +import removePathInjectable from "../../common/fs/remove-path.injectable"; +import homeDirectoryPathInjectable from "../../common/os/home-directory-path.injectable"; const extensionDiscoveryInjectable = getInjectable({ id: "extension-discovery", - instantiate: (di) => - new ExtensionDiscovery({ - extensionLoader: di.inject(extensionLoaderInjectable), - extensionsStore: di.inject(extensionsStoreInjectable), - extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable), - isCompatibleExtension: di.inject(isCompatibleExtensionInjectable), - installExtension: di.inject(installExtensionInjectable), - installExtensions: di.inject(installExtensionsInjectable), - extensionPackageRootDirectory: di.inject(extensionPackageRootDirectoryInjectable), - staticFilesDirectory: di.inject(staticFilesDirectoryInjectable), - readJsonFile: di.inject(readJsonFileInjectable), - pathExists: di.inject(pathExistsInjectable), - watch: di.inject(watchInjectable), - logger: di.inject(loggerInjectable), - }), + instantiate: (di) => new ExtensionDiscovery({ + extensionLoader: di.inject(extensionLoaderInjectable), + extensionsStore: di.inject(extensionsStoreInjectable), + extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable), + isCompatibleExtension: di.inject(isCompatibleExtensionInjectable), + installExtension: di.inject(installExtensionInjectable), + installExtensions: di.inject(installExtensionsInjectable), + extensionPackageRootDirectory: di.inject(extensionPackageRootDirectoryInjectable), + staticFilesDirectory: di.inject(staticFilesDirectoryInjectable), + readJsonFile: di.inject(readJsonFileInjectable), + pathExists: di.inject(pathExistsInjectable), + watch: di.inject(watchInjectable), + logger: di.inject(loggerInjectable), + accessPath: di.inject(accessPathInjectable), + copy: di.inject(copyInjectable), + removePath: di.inject(removePathInjectable), + ensureDirectory: di.inject(ensureDirInjectable), + isProduction: di.inject(isProductionInjectable), + lstat: di.inject(lstatInjectable), + readDirectory: di.inject(readDirectoryInjectable), + fileSystemSeparator: di.inject(fileSystemSeparatorInjectable), + getBasenameOfPath: di.inject(getBasenameOfPathInjectable), + getDirnameOfPath: di.inject(getDirnameOfPathInjectable), + getRelativePath: di.inject(getRelativePathInjectable), + joinPaths: di.inject(joinPathsInjectable), + homeDirectoryPath: di.inject(homeDirectoryPathInjectable), + }), }); export default extensionDiscoveryInjectable; diff --git a/src/extensions/extension-discovery/extension-discovery.test.ts b/src/extensions/extension-discovery/extension-discovery.test.ts index 7feca8f99d..f3630addaf 100644 --- a/src/extensions/extension-discovery/extension-discovery.test.ts +++ b/src/extensions/extension-discovery/extension-discovery.test.ts @@ -4,36 +4,39 @@ */ import type { FSWatcher } from "chokidar"; -import path from "path"; -import os from "os"; -import { Console } from "console"; import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; import extensionDiscoveryInjectable from "../extension-discovery/extension-discovery.injectable"; import type { ExtensionDiscovery } from "../extension-discovery/extension-discovery"; import installExtensionInjectable from "../extension-installer/install-extension/install-extension.injectable"; import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; -import mockFs from "mock-fs"; import { delay } from "../../renderer/utils"; -import { observable, when } from "mobx"; -import appVersionInjectable from "../../common/vars/app-version.injectable"; +import { observable, runInAction, when } from "mobx"; import readJsonFileInjectable from "../../common/fs/read-json-file.injectable"; import pathExistsInjectable from "../../common/fs/path-exists.injectable"; import watchInjectable from "../../common/fs/watch/watch.injectable"; - -console = new Console(process.stdout, process.stderr); // fix mockFS +import extensionApiVersionInjectable from "../../common/vars/extension-api-version.injectable"; +import removePathInjectable from "../../common/fs/remove-path.injectable"; +import type { JoinPaths } from "../../common/path/join-paths.injectable"; +import joinPathsInjectable from "../../common/path/join-paths.injectable"; +import homeDirectoryPathInjectable from "../../common/os/home-directory-path.injectable"; describe("ExtensionDiscovery", () => { let extensionDiscovery: ExtensionDiscovery; let readJsonFileMock: jest.Mock; let pathExistsMock: jest.Mock; let watchMock: jest.Mock; + let joinPaths: JoinPaths; + let homeDirectoryPath: string; beforeEach(() => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); - di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data"); di.override(installExtensionInjectable, () => () => Promise.resolve()); - di.override(appVersionInjectable, () => "5.0.0"); + di.override(extensionApiVersionInjectable, () => "5.0.0"); + + joinPaths = di.inject(joinPathsInjectable); + homeDirectoryPath = di.inject(homeDirectoryPathInjectable); readJsonFileMock = jest.fn(); di.override(readJsonFileInjectable, () => readJsonFileMock); @@ -44,21 +47,17 @@ describe("ExtensionDiscovery", () => { watchMock = jest.fn(); di.override(watchInjectable, () => watchMock); - mockFs(); + di.override(removePathInjectable, () => async () => {}); // allow deleting files for now extensionDiscovery = di.inject(extensionDiscoveryInjectable); }); - afterEach(() => { - mockFs.restore(); - }); - it("emits add for added extension", async () => { const letTestFinish = observable.box(false); let addHandler!: (filePath: string) => void; readJsonFileMock.mockImplementation((p) => { - expect(p).toBe(path.join(os.homedir(), ".k8slens/extensions/my-extension/package.json")); + expect(p).toBe(joinPaths(homeDirectoryPath, ".k8slens/extensions/my-extension/package.json")); return { name: "my-extension", @@ -89,7 +88,7 @@ describe("ExtensionDiscovery", () => { extensionDiscovery.events.on("add", extension => { expect(extension).toEqual({ absolutePath: expect.any(String), - id: path.normalize("some-directory-for-user-data/node_modules/my-extension/package.json"), + id: "/some-directory-for-user-data/node_modules/my-extension/package.json", isBundled: false, isEnabled: false, isCompatible: true, @@ -100,12 +99,12 @@ describe("ExtensionDiscovery", () => { lens: "5.0.0", }, }, - manifestPath: path.normalize("some-directory-for-user-data/node_modules/my-extension/package.json"), + manifestPath: "/some-directory-for-user-data/node_modules/my-extension/package.json", }); - letTestFinish.set(true); + runInAction(() => letTestFinish.set(true)); }); - addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/package.json")); + addHandler(joinPaths(extensionDiscovery.localFolderPath, "/my-extension/package.json")); await when(() => letTestFinish.get()); }); @@ -133,7 +132,7 @@ describe("ExtensionDiscovery", () => { extensionDiscovery.events.on("add", onAdd); - addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json")); + addHandler(joinPaths(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json")); await delay(10); diff --git a/src/extensions/extension-discovery/extension-discovery.ts b/src/extensions/extension-discovery/extension-discovery.ts index 40df43ebdf..c7781d78a6 100644 --- a/src/extensions/extension-discovery/extension-discovery.ts +++ b/src/extensions/extension-discovery/extension-discovery.ts @@ -5,16 +5,12 @@ import { ipcRenderer } from "electron"; import { EventEmitter } from "events"; -import fse from "fs-extra"; import { makeObservable, observable, reaction, when } from "mobx"; -import os from "os"; -import path from "path"; import { broadcastMessage, ipcMainHandle, ipcRendererOn } from "../../common/ipc"; import { isErrnoException, toJS } from "../../common/utils"; import type { ExtensionsStore } from "../extensions-store/extensions-store"; import type { ExtensionLoader } from "../extension-loader"; import type { LensExtensionId, LensExtensionManifest } from "../lens-extension"; -import { isProduction } from "../../common/vars"; import type { ExtensionInstallationStateStore } from "../extension-installation-state-store/extension-installation-state-store"; import type { PackageJson } from "type-fest"; import { extensionDiscoveryStateChannel } from "../../common/ipc/extension-handling"; @@ -23,20 +19,46 @@ import type { ReadJson } from "../../common/fs/read-json-file.injectable"; import type { Logger } from "../../common/logger"; import type { PathExists } from "../../common/fs/path-exists.injectable"; import type { Watch } from "../../common/fs/watch/watch.injectable"; +import type { Stats } from "fs"; +import { constants } from "fs"; +import type { LStat } from "../../common/fs/lstat.injectable"; +import type { ReadDirectory } from "../../common/fs/read-directory.injectable"; +import type { EnsureDirectory } from "../../common/fs/ensure-dir.injectable"; +import type { AccessPath } from "../../common/fs/access-path.injectable"; +import type { Copy } from "../../common/fs/copy.injectable"; +import type { JoinPaths } from "../../common/path/join-paths.injectable"; +import type { GetBasenameOfPath } from "../../common/path/get-basename.injectable"; +import type { GetDirnameOfPath } from "../../common/path/get-dirname.injectable"; +import type { GetRelativePath } from "../../common/path/get-relative-path.injectable"; +import type { RemovePath } from "../../common/fs/remove-path.injectable"; +import type TypedEventEmitter from "typed-emitter"; interface Dependencies { - extensionLoader: ExtensionLoader; - extensionsStore: ExtensionsStore; - extensionInstallationStateStore: ExtensionInstallationStateStore; + readonly extensionLoader: ExtensionLoader; + readonly extensionsStore: ExtensionsStore; + readonly extensionInstallationStateStore: ExtensionInstallationStateStore; + readonly extensionPackageRootDirectory: string; + readonly staticFilesDirectory: string; + readonly logger: Logger; + readonly isProduction: boolean; + readonly fileSystemSeparator: string; + readonly homeDirectoryPath: string; isCompatibleExtension: (manifest: LensExtensionManifest) => boolean; installExtension: (name: string) => Promise; installExtensions: (packageJsonPath: string, packagesJson: PackageJson) => Promise; - extensionPackageRootDirectory: string; - staticFilesDirectory: string; readJsonFile: ReadJson; pathExists: PathExists; + removePath: RemovePath; + lstat: LStat; watch: Watch; - logger: Logger; + readDirectory: ReadDirectory; + ensureDirectory: EnsureDirectory; + accessPath: AccessPath; + copy: Copy; + joinPaths: JoinPaths; + getBasenameOfPath: GetBasenameOfPath; + getDirnameOfPath: GetDirnameOfPath; + getRelativePath: GetRelativePath; } export interface InstalledExtension { @@ -67,12 +89,17 @@ interface ExtensionDiscoveryChannelMessage { * Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link) * @param lstat the stats to compare */ -const isDirectoryLike = (lstat: fse.Stats) => lstat.isDirectory() || lstat.isSymbolicLink(); +const isDirectoryLike = (lstat: Stats) => lstat.isDirectory() || lstat.isSymbolicLink(); interface LoadFromFolderOptions { isBundled?: boolean; } +interface ExtensionDiscoveryEvents { + add: (ext: InstalledExtension) => void; + remove: (extId: LensExtensionId) => void; +} + /** * Discovers installed bundled and local extensions from the filesystem. * Also watches for added and removed local extensions by watching the directory. @@ -96,30 +123,30 @@ export class ExtensionDiscovery { return when(() => this.isLoaded); } - public events = new EventEmitter(); + public readonly events: TypedEventEmitter = new EventEmitter(); constructor(protected readonly dependencies: Dependencies) { makeObservable(this); } get localFolderPath(): string { - return path.join(os.homedir(), ".k8slens", "extensions"); + return this.dependencies.joinPaths(this.dependencies.homeDirectoryPath, ".k8slens", "extensions"); } get packageJsonPath(): string { - return path.join(this.dependencies.extensionPackageRootDirectory, manifestFilename); + return this.dependencies.joinPaths(this.dependencies.extensionPackageRootDirectory, manifestFilename); } get inTreeTargetPath(): string { - return path.join(this.dependencies.extensionPackageRootDirectory, "extensions"); + return this.dependencies.joinPaths(this.dependencies.extensionPackageRootDirectory, "extensions"); } get inTreeFolderPath(): string { - return path.resolve(this.dependencies.staticFilesDirectory, "../extensions"); + return this.dependencies.joinPaths(this.dependencies.staticFilesDirectory, "../extensions"); } get nodeModulesPath(): string { - return path.join(this.dependencies.extensionPackageRootDirectory, "node_modules"); + return this.dependencies.joinPaths(this.dependencies.extensionPackageRootDirectory, "node_modules"); } /** @@ -184,24 +211,24 @@ export class ExtensionDiscovery { handleWatchFileAdd = async (manifestPath: string): Promise => { // e.g. "foo/package.json" - const relativePath = path.relative(this.localFolderPath, manifestPath); + const relativePath = this.dependencies.getRelativePath(this.localFolderPath, manifestPath); // Converts "foo/package.json" to ["foo", "package.json"], where length of 2 implies // that the added file is in a folder under local folder path. // This safeguards against a file watch being triggered under a sub-directory which is not an extension. - const isUnderLocalFolderPath = relativePath.split(path.sep).length === 2; + const isUnderLocalFolderPath = relativePath.split(this.dependencies.fileSystemSeparator).length === 2; - if (path.basename(manifestPath) === manifestFilename && isUnderLocalFolderPath) { + if (this.dependencies.getBasenameOfPath(manifestPath) === manifestFilename && isUnderLocalFolderPath) { try { this.dependencies.extensionInstallationStateStore.setInstallingFromMain(manifestPath); - const absPath = path.dirname(manifestPath); + const absPath = this.dependencies.getDirnameOfPath(manifestPath); // this.loadExtensionFromPath updates this.packagesJson const extension = await this.loadExtensionFromFolder(absPath); if (extension) { // Remove a broken symlink left by a previous installation if it exists. - await fse.remove(extension.manifestPath); + await this.dependencies.removePath(extension.manifestPath); // Install dependencies for the new extension await this.dependencies.installExtension(extension.absolutePath); @@ -226,8 +253,8 @@ export class ExtensionDiscovery { handleWatchUnlinkEvent = async (filePath: string): Promise => { // Check that the removed path is directly under this.localFolderPath // Note that the watcher can create unlink events for subdirectories of the extension - const extensionFolderName = path.basename(filePath); - const expectedPath = path.relative(this.localFolderPath, filePath); + const extensionFolderName = this.dependencies.getBasenameOfPath(filePath); + const expectedPath = this.dependencies.getRelativePath(this.localFolderPath, filePath); if (expectedPath !== extensionFolderName) { return; @@ -264,7 +291,7 @@ export class ExtensionDiscovery { * @param name e.g. "@mirantis/lens-extension-cc" */ removeSymlinkByPackageName(name: string): Promise { - return fse.remove(this.getInstalledPath(name)); + return this.dependencies.removePath(this.getInstalledPath(name)); } /** @@ -286,7 +313,7 @@ export class ExtensionDiscovery { await this.removeSymlinkByPackageName(manifest.name); // fs.remove does nothing if the path doesn't exist anymore - await fse.remove(absolutePath); + await this.dependencies.removePath(absolutePath); } async load(): Promise> { @@ -301,34 +328,29 @@ export class ExtensionDiscovery { `${logModule} loading extensions from ${this.dependencies.extensionPackageRootDirectory}`, ); - // fs.remove won't throw if path is missing - await fse.remove(path.join(this.dependencies.extensionPackageRootDirectory, "package-lock.json")); + await this.dependencies.removePath(this.dependencies.joinPaths(this.dependencies.extensionPackageRootDirectory, "package-lock.json")); - try { - // Verify write access to static/extensions, which is needed for symlinking - await fse.access(this.inTreeFolderPath, fse.constants.W_OK); + const canWriteToInTreeFolder = await this.dependencies.accessPath(this.inTreeFolderPath, constants.W_OK); + if (canWriteToInTreeFolder) { // Set bundled folder path to static/extensions this.bundledFolderPath = this.inTreeFolderPath; - } catch { - // If there is error accessing static/extensions, we need to copy in-tree extensions so that we can symlink them properly on "npm install". - // The error can happen if there is read-only rights to static/extensions, which would fail symlinking. - + } else { // Remove e.g. /Users//Library/Application Support/LensDev/extensions - await fse.remove(this.inTreeTargetPath); + await this.dependencies.removePath(this.inTreeTargetPath); // Create folder e.g. /Users//Library/Application Support/LensDev/extensions - await fse.ensureDir(this.inTreeTargetPath); + await this.dependencies.ensureDirectory(this.inTreeTargetPath); // Copy static/extensions to e.g. /Users//Library/Application Support/LensDev/extensions - await fse.copy(this.inTreeFolderPath, this.inTreeTargetPath); + await this.dependencies.copy(this.inTreeFolderPath, this.inTreeTargetPath); // Set bundled folder path to e.g. /Users//Library/Application Support/LensDev/extensions this.bundledFolderPath = this.inTreeTargetPath; } - await fse.ensureDir(this.nodeModulesPath); - await fse.ensureDir(this.localFolderPath); + await this.dependencies.ensureDirectory(this.nodeModulesPath); + await this.dependencies.ensureDirectory(this.localFolderPath); const extensions = await this.ensureExtensions(); @@ -342,7 +364,7 @@ export class ExtensionDiscovery { * e.g. "/Users//Library/Application Support/Lens/node_modules/@publisher/extension" */ protected getInstalledPath(name: string): string { - return path.join(this.nodeModulesPath, name); + return this.dependencies.joinPaths(this.nodeModulesPath, name); } /** @@ -350,7 +372,7 @@ export class ExtensionDiscovery { * e.g. "/Users//Library/Application Support/Lens/node_modules/@publisher/extension/package.json" */ protected getInstalledManifestPath(name: string): string { - return path.join(this.getInstalledPath(name), manifestFilename); + return this.dependencies.joinPaths(this.getInstalledPath(name), manifestFilename); } /** @@ -362,9 +384,11 @@ export class ExtensionDiscovery { const manifest = await this.dependencies.readJsonFile(manifestPath) as unknown as LensExtensionManifest; const id = this.getInstalledManifestPath(manifest.name); const isEnabled = this.dependencies.extensionsStore.isEnabled({ id, isBundled }); - const extensionDir = path.dirname(manifestPath); - const npmPackage = path.join(extensionDir, `${manifest.name}-${manifest.version}.tgz`); - const absolutePath = (isProduction && await this.dependencies.pathExists(npmPackage)) ? npmPackage : extensionDir; + const extensionDir = this.dependencies.getDirnameOfPath(manifestPath); + const npmPackage = this.dependencies.joinPaths(extensionDir, `${manifest.name}-${manifest.version}.tgz`); + const absolutePath = this.dependencies.isProduction && await this.dependencies.pathExists(npmPackage) + ? npmPackage + : extensionDir; const isCompatible = isBundled || this.dependencies.isCompatibleExtension(manifest); return { @@ -390,28 +414,11 @@ export class ExtensionDiscovery { async ensureExtensions(): Promise> { const bundledExtensions = await this.loadBundledExtensions(); - - await this.installBundledPackages(this.packageJsonPath, bundledExtensions); - const userExtensions = await this.loadFromFolder(this.localFolderPath, bundledExtensions.map((extension) => extension.manifest.name)); - - for (const extension of userExtensions) { - if (!(await this.dependencies.pathExists(extension.manifestPath))) { - try { - await this.dependencies.installExtension(extension.absolutePath); - } catch (error) { - const message = error instanceof Error - ? error.message - : String(error || "unknown error"); - const { name, version } = extension.manifest; - - this.dependencies.logger.error(`${logModule}: failed to install user extension ${name}@${version}: ${message}`); - } - } - } - const extensions = bundledExtensions.concat(userExtensions); + await this.installBundledPackages(this.packageJsonPath, extensions); + return this.extensions = new Map(extensions.map(extension => [extension.id, extension])); } @@ -420,19 +427,22 @@ export class ExtensionDiscovery { */ installBundledPackages(packageJsonPath: string, extensions: InstalledExtension[]): Promise { const dependencies = Object.fromEntries( - extensions.map(extension => [extension.manifest.name, extension.absolutePath]), + extensions.filter(extension => extension.isBundled).map(extension => [extension.manifest.name, extension.absolutePath]), + ); + const optionalDependencies = Object.fromEntries( + extensions.filter(extension => !extension.isBundled).map(extension => [extension.manifest.name, extension.absolutePath]), ); - return this.dependencies.installExtensions(packageJsonPath, { dependencies }); + return this.dependencies.installExtensions(packageJsonPath, { dependencies, optionalDependencies }); } async loadBundledExtensions(): Promise { const extensions: InstalledExtension[] = []; const folderPath = this.bundledFolderPath; - const paths = await fse.readdir(folderPath); + const paths = await this.dependencies.readDirectory(folderPath); for (const fileName of paths) { - const absPath = path.resolve(folderPath, fileName); + const absPath = this.dependencies.joinPaths(folderPath, fileName); const extension = await this.loadExtensionFromFolder(absPath, { isBundled: true }); if (extension) { @@ -446,7 +456,7 @@ export class ExtensionDiscovery { async loadFromFolder(folderPath: string, bundledExtensions: string[]): Promise { const extensions: InstalledExtension[] = []; - const paths = await fse.readdir(folderPath); + const paths = await this.dependencies.readDirectory(folderPath); for (const fileName of paths) { // do not allow to override bundled extensions @@ -454,17 +464,21 @@ export class ExtensionDiscovery { continue; } - const absPath = path.resolve(folderPath, fileName); + const absPath = this.dependencies.joinPaths(folderPath, fileName); - if (!fse.existsSync(absPath)) { - continue; - } + try { + const lstat = await this.dependencies.lstat(absPath); - const lstat = await fse.lstat(absPath); + // skip non-directories + if (!isDirectoryLike(lstat)) { + continue; + } + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") { + continue; + } - // skip non-directories - if (!isDirectoryLike(lstat)) { - continue; + throw error; } const extension = await this.loadExtensionFromFolder(absPath); @@ -484,7 +498,7 @@ export class ExtensionDiscovery { * @param folderPath Folder path to extension */ async loadExtensionFromFolder(folderPath: string, { isBundled = false }: LoadFromFolderOptions = {}): Promise { - const manifestPath = path.resolve(folderPath, manifestFilename); + const manifestPath = this.dependencies.joinPaths(folderPath, manifestFilename); return this.getByManifest(manifestPath, { isBundled }); } diff --git a/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.injectable.ts b/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.injectable.ts index 75e2f45d4a..de2fd4390f 100644 --- a/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.injectable.ts +++ b/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.injectable.ts @@ -3,13 +3,13 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import appSemanticVersionInjectable from "../../../common/vars/app-semantic-version.injectable"; +import extensionApiVersionInjectable from "../../../common/vars/extension-api-version.injectable"; import { isCompatibleExtension } from "./is-compatible-extension"; const isCompatibleExtensionInjectable = getInjectable({ id: "is-compatible-extension", instantiate: (di) => isCompatibleExtension({ - appSemVer: di.inject(appSemanticVersionInjectable), + extensionApiVersion: di.inject(extensionApiVersionInjectable), }), }); diff --git a/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.ts b/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.ts index 717effa1c6..74cbb4fd0c 100644 --- a/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.ts +++ b/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.ts @@ -2,16 +2,15 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import semver, { type SemVer } from "semver"; +import semver from "semver"; import type { LensExtensionManifest } from "../../lens-extension"; interface Dependencies { - appSemVer: SemVer; + extensionApiVersion: string; } -export const isCompatibleExtension = ({ appSemVer }: Dependencies): ((manifest: LensExtensionManifest) => boolean) => { +export const isCompatibleExtension = ({ extensionApiVersion }: Dependencies): ((manifest: LensExtensionManifest) => boolean) => { return (manifest: LensExtensionManifest): boolean => { - const appVersion = appSemVer.raw.split("-")[0]; // drop prerelease version if any, e.g. "-alpha.0" const manifestLensEngine = manifest.engines.lens; const validVersion = manifestLensEngine.match(/^[\^0-9]\d*\.\d+\b/); // must start from ^ or number @@ -30,7 +29,7 @@ export const isCompatibleExtension = ({ appSemVer }: Dependencies): ((manifest: }) as semver.SemVer; const supportedVersionsByExtension = semver.validRange(`^${extMajor}.${extMinor}`) as string; - return semver.satisfies(appVersion, supportedVersionsByExtension, { + return semver.satisfies(extensionApiVersion, supportedVersionsByExtension, { loose: true, includePrerelease: false, }); diff --git a/src/extensions/extension-installer/extension-installer.ts b/src/extensions/extension-installer/extension-installer.ts index a2781f15bc..de97c71f00 100644 --- a/src/extensions/extension-installer/extension-installer.ts +++ b/src/extensions/extension-installer/extension-installer.ts @@ -16,6 +16,16 @@ interface Dependencies { extensionPackageRootDirectory: string; } +const baseNpmInstallArgs = [ + "install", + "--audit=false", + "--fund=false", + // NOTE: we do not omit the `optional` dependencies because that is how we specify the non-bundled extensions + "--omit=dev", + "--omit=peer", + "--prefer-offline", +]; + /** * Installs dependencies for extensions */ @@ -42,7 +52,7 @@ export class ExtensionInstaller { }); logger.info(`${logModule} installing dependencies at ${this.dependencies.extensionPackageRootDirectory}`); - await this.npm(["install", "--no-audit", "--only=prod", "--prefer-offline", "--no-package-lock"]); + await this.npm(...baseNpmInstallArgs); logger.info(`${logModule} dependencies installed at ${this.dependencies.extensionPackageRootDirectory}`); } finally { this.installLock.release(); @@ -58,14 +68,14 @@ export class ExtensionInstaller { try { logger.info(`${logModule} installing package from ${name} to ${this.dependencies.extensionPackageRootDirectory}`); - await this.npm(["install", "--no-audit", "--only=prod", "--package-lock=false", "--prefer-offline", "--no-package-lock", name]); + await this.npm(...baseNpmInstallArgs, name); logger.info(`${logModule} package ${name} installed to ${this.dependencies.extensionPackageRootDirectory}`); } finally { this.installLock.release(); } }; - private npm(args: string[]): Promise { + private npm(...args: string[]): Promise { return new Promise((resolve, reject) => { const child = child_process.fork(this.npmPath, args, { cwd: this.dependencies.extensionPackageRootDirectory, diff --git a/src/extensions/extension-loader/extension-loader.injectable.ts b/src/extensions/extension-loader/extension-loader.injectable.ts index 7fe1cd5421..48edce4446 100644 --- a/src/extensions/extension-loader/extension-loader.injectable.ts +++ b/src/extensions/extension-loader/extension-loader.injectable.ts @@ -9,6 +9,9 @@ import { createExtensionInstanceInjectionToken } from "./create-extension-instan import extensionInstancesInjectable from "./extension-instances.injectable"; import type { LensExtension } from "../lens-extension"; import extensionInjectable from "./extension/extension.injectable"; +import loggerInjectable from "../../common/logger.injectable"; +import joinPathsInjectable from "../../common/path/join-paths.injectable"; +import getDirnameOfPathInjectable from "../../common/path/get-dirname.injectable"; const extensionLoaderInjectable = getInjectable({ id: "extension-loader", @@ -18,6 +21,9 @@ const extensionLoaderInjectable = getInjectable({ createExtensionInstance: di.inject(createExtensionInstanceInjectionToken), extensionInstances: di.inject(extensionInstancesInjectable), getExtension: (instance: LensExtension) => di.inject(extensionInjectable, instance), + logger: di.inject(loggerInjectable), + joinPaths: di.inject(joinPathsInjectable), + getDirnameOfPath: di.inject(getDirnameOfPathInjectable), }), }); diff --git a/src/extensions/extension-loader/extension-loader.ts b/src/extensions/extension-loader/extension-loader.ts index 16b28de8e1..5c63451276 100644 --- a/src/extensions/extension-loader/extension-loader.ts +++ b/src/extensions/extension-loader/extension-loader.ts @@ -7,11 +7,9 @@ import { ipcMain, ipcRenderer } from "electron"; import { isEqual } from "lodash"; import type { ObservableMap } from "mobx"; import { action, computed, makeObservable, observable, observe, reaction, when } from "mobx"; -import path from "path"; import { broadcastMessage, ipcMainOn, ipcRendererOn, ipcMainHandle } from "../../common/ipc"; import type { Disposer } from "../../common/utils"; import { isDefined, toJS } from "../../common/utils"; -import logger from "../../main/logger"; import type { InstalledExtension } from "../extension-discovery/extension-discovery"; import type { LensExtension, LensExtensionConstructor, LensExtensionId } from "../lens-extension"; import type { LensRendererExtension } from "../lens-renderer-extension"; @@ -23,14 +21,20 @@ import assert from "assert"; import { EventEmitter } from "../../common/event-emitter"; import type { CreateExtensionInstance } from "./create-extension-instance.token"; import type { Extension } from "./extension/extension.injectable"; +import type { Logger } from "../../common/logger"; +import type { JoinPaths } from "../../common/path/join-paths.injectable"; +import type { GetDirnameOfPath } from "../../common/path/get-dirname.injectable"; const logModule = "[EXTENSIONS-LOADER]"; interface Dependencies { + readonly extensionInstances: ObservableMap; + readonly logger: Logger; updateExtensionsState: (extensionsState: Record) => void; createExtensionInstance: CreateExtensionInstance; - readonly extensionInstances: ObservableMap; getExtension: (instance: LensExtension) => Extension; + joinPaths: JoinPaths; + getDirnameOfPath: GetDirnameOfPath; } export interface ExtensionLoading { @@ -159,7 +163,7 @@ export class ExtensionLoader { @action removeInstance(lensExtensionId: LensExtensionId) { - logger.info(`${logModule} deleting extension instance ${lensExtensionId}`); + this.dependencies.logger.info(`${logModule} deleting extension instance ${lensExtensionId}`); const instance = this.dependencies.extensionInstances.get(lensExtensionId); if (!instance) { @@ -177,7 +181,7 @@ export class ExtensionLoader { this.dependencies.extensionInstances.delete(lensExtensionId); this.nonInstancesByName.delete(instance.name); } catch (error) { - logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error }); + this.dependencies.logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error }); } } @@ -252,7 +256,7 @@ export class ExtensionLoader { } loadOnClusterManagerRenderer = () => { - logger.debug(`${logModule}: load on main renderer (cluster manager)`); + this.dependencies.logger.debug(`${logModule}: load on main renderer (cluster manager)`); return this.autoInitExtensions(async (ext) => { const extension = ext as LensRendererExtension; @@ -274,7 +278,7 @@ export class ExtensionLoader { }; loadOnClusterRenderer = () => { - logger.debug(`${logModule}: load on cluster renderer (dashboard)`); + this.dependencies.logger.debug(`${logModule}: load on cluster renderer (dashboard)`); this.autoInitExtensions(async () => []); }; @@ -313,7 +317,7 @@ export class ExtensionLoader { activated: instance.activate(), }; } catch (err) { - logger.error(`${logModule}: error loading extension`, { ext: extension, err }); + this.dependencies.logger.error(`${logModule}: error loading extension`, { ext: extension, err }); } } else if (!extension.isEnabled && alreadyInit) { this.removeInstance(extId); @@ -330,7 +334,7 @@ export class ExtensionLoader { extensions.map(extension => // If extension activation fails, log error extension.activated.catch((error) => { - logger.error(`${logModule}: activation extension error`, { ext: extension.installedExtension, error }); + this.dependencies.logger.error(`${logModule}: activation extension error`, { ext: extension.installedExtension, error }); }), ), ); @@ -344,7 +348,7 @@ export class ExtensionLoader { // Return ExtensionLoading[] return extensions.map(extension => { const loaded = extension.instance.enable(register).catch((err) => { - logger.error(`${logModule}: failed to enable`, { ext: extension, err }); + this.dependencies.logger.error(`${logModule}: failed to enable`, { ext: extension, err }); }); return { @@ -370,14 +374,14 @@ export class ExtensionLoader { return null; } - const extAbsolutePath = path.resolve(path.join(path.dirname(extension.manifestPath), extRelativePath)); + const extAbsolutePath = this.dependencies.joinPaths(this.dependencies.getDirnameOfPath(extension.manifestPath), extRelativePath); try { return __non_webpack_require__(extAbsolutePath).default; } catch (error) { const message = (error instanceof Error ? error.stack : undefined) || error; - logger.error(`${logModule}: can't load ${entryPointName} for "${extension.manifest.name}": ${message}`, { extension }); + this.dependencies.logger.error(`${logModule}: can't load ${entryPointName} for "${extension.manifest.name}": ${message}`, { extension }); } return null; diff --git a/src/extensions/extension-loader/extension-registrator-injection-token.ts b/src/extensions/extension-loader/extension-registrator-injection-token.ts index b5527cd00d..295d3b67a4 100644 --- a/src/extensions/extension-loader/extension-registrator-injection-token.ts +++ b/src/extensions/extension-loader/extension-registrator-injection-token.ts @@ -4,10 +4,12 @@ */ import type { Injectable } from "@ogre-tools/injectable"; import { getInjectionToken } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; import type { LensExtension } from "../lens-extension"; -export const extensionRegistratorInjectionToken = getInjectionToken< - (extension: LensExtension) => Injectable[] - >({ - id: "extension-registrator-token", - }); +export type ExtensionRegistrator = (extension: LensExtension) => + Injectable[] | IComputedValue[]>; + +export const extensionRegistratorInjectionToken = getInjectionToken({ + id: "extension-registrator-token", +}); diff --git a/src/extensions/extension-loader/extension/extension.injectable.ts b/src/extensions/extension-loader/extension/extension.injectable.ts index 7ed23a3dfa..6b9424cea4 100644 --- a/src/extensions/extension-loader/extension/extension.injectable.ts +++ b/src/extensions/extension-loader/extension/extension.injectable.ts @@ -2,8 +2,11 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { Injectable } from "@ogre-tools/injectable"; import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { runInAction } from "mobx"; +import { difference, find, map } from "lodash"; +import { reaction, runInAction } from "mobx"; +import { disposer } from "../../../common/utils/disposer"; import type { LensExtension } from "../../lens-extension"; import { extensionRegistratorInjectionToken } from "../extension-registrator-injection-token"; @@ -12,6 +15,16 @@ export interface Extension { deregister: () => void; } +const idsToInjectables = (ids: string[], injectables: Injectable[]) => ids.map(id => { + const injectable = find(injectables, { id }); + + if (!injectable) { + throw new Error(`Injectable ${id} not found`); + } + + return injectable; +}); + const extensionInjectable = getInjectable({ id: "extension", @@ -21,19 +34,42 @@ const extensionInjectable = getInjectable({ instantiate: (childDi) => { const extensionRegistrators = childDi.injectMany(extensionRegistratorInjectionToken); + const reactionDisposer = disposer(); return { register: () => { - const injectables = extensionRegistrators.flatMap((getInjectablesOfExtension) => - getInjectablesOfExtension(instance), - ); + extensionRegistrators.forEach((getInjectablesOfExtension) => { + const injectables = getInjectablesOfExtension(instance); - runInAction(() => { - childDi.register(...injectables); + reactionDisposer.push( + // injectables is either an array or a computed array, in which case + // we need to update the registered injectables with a reaction every time they change + reaction( + () => Array.isArray(injectables) ? injectables : injectables.get(), + (currentInjectables, previousInjectables = []) => { + // Register new injectables and deregister removed injectables by id + const currentIds = map(currentInjectables, "id"); + const previousIds = map(previousInjectables, "id"); + const idsToAdd = difference(currentIds, previousIds); + const idsToRemove = previousIds.filter(previousId => !currentIds.includes(previousId)); + + if (idsToRemove.length > 0) { + childDi.deregister(...idsToInjectables(idsToRemove, previousInjectables)); + } + + if (idsToAdd.length > 0) { + childDi.register(...idsToInjectables(idsToAdd, currentInjectables)); + } + }, { + fireImmediately: true, + }, + )); }); }, deregister: () => { + reactionDisposer(); + runInAction(() => { parentDi.deregister(extensionInjectable); }); diff --git a/src/extensions/extension-loader/file-system-provisioner-store/directory-for-extension-data.injectable.ts b/src/extensions/extension-loader/file-system-provisioner-store/directory-for-extension-data.injectable.ts index 896c74da6d..469ed85949 100644 --- a/src/extensions/extension-loader/file-system-provisioner-store/directory-for-extension-data.injectable.ts +++ b/src/extensions/extension-loader/file-system-provisioner-store/directory-for-extension-data.injectable.ts @@ -4,16 +4,16 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; -import getAbsolutePathInjectable from "../../../common/path/get-absolute-path.injectable"; +import joinPathsInjectable from "../../../common/path/join-paths.injectable"; const directoryForExtensionDataInjectable = getInjectable({ id: "directory-for-extension-data", instantiate: (di) => { - const getAbsolutePath = di.inject(getAbsolutePathInjectable); + const joinPaths = di.inject(joinPathsInjectable); const directoryForUserData = di.inject(directoryForUserDataInjectable); - return getAbsolutePath(directoryForUserData, "extension_data"); + return joinPaths(directoryForUserData, "extension_data"); }, }); diff --git a/src/extensions/lens-main-extension.ts b/src/extensions/lens-main-extension.ts index accc87989c..b97d8da834 100644 --- a/src/extensions/lens-main-extension.ts +++ b/src/extensions/lens-main-extension.ts @@ -5,15 +5,15 @@ import { LensExtension, lensExtensionDependencies } from "./lens-extension"; import type { CatalogEntity } from "../common/catalog"; -import type { IObservableArray } from "mobx"; -import type { MenuRegistration } from "../main/menu/menu-registration"; +import type { IComputedValue, IObservableArray } from "mobx"; +import type { MenuRegistration } from "../features/application-menu/main/menu-registration"; import type { TrayMenuRegistration } from "../main/tray/tray-menu-registration"; import type { ShellEnvModifier } from "../main/shell-session/shell-env-modifier/shell-env-modifier-registration"; import type { LensMainExtensionDependencies } from "./lens-extension-set-dependencies"; export class LensMainExtension extends LensExtension { appMenus: MenuRegistration[] = []; - trayMenus: TrayMenuRegistration[] = []; + trayMenus: TrayMenuRegistration[] | IComputedValue = []; /** * implement this to modify the shell environment that Lens terminals are opened with. The ShellEnvModifier type has the signature diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index d744d261d4..d0a2a979e6 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -14,7 +14,7 @@ import type { KubernetesCluster } from "../common/catalog-entities"; import type { WelcomeMenuRegistration } from "../renderer/components/+welcome/welcome-menu-items/welcome-menu-registration"; import type { WelcomeBannerRegistration } from "../renderer/components/+welcome/welcome-banner-items/welcome-banner-registration"; import type { CommandRegistration } from "../renderer/components/command-palette/registered-commands/commands"; -import type { AppPreferenceRegistration } from "../renderer/components/+preferences/app-preferences/app-preference-registration"; +import type { AppPreferenceRegistration } from "../features/preferences/renderer/compliance-for-legacy-extension-api/app-preference-registration"; import type { AdditionalCategoryColumnRegistration } from "../renderer/components/+catalog/custom-category-columns"; import type { CustomCategoryViewRegistration } from "../renderer/components/+catalog/custom-views"; import type { StatusBarRegistration } from "../renderer/components/status-bar/status-bar-registration"; @@ -26,13 +26,15 @@ import { pipeline } from "@ogre-tools/fp"; import { getExtensionRoutePath } from "../renderer/routes/for-extension"; import type { LensRendererExtensionDependencies } from "./lens-extension-set-dependencies"; import type { KubeObjectHandlerRegistration } from "../renderer/kube-object/handler"; -import type { AppPreferenceTabRegistration } from "../renderer/components/+preferences/app-preference-tab/app-preference-tab-registration"; +import type { AppPreferenceTabRegistration } from "../features/preferences/renderer/compliance-for-legacy-extension-api/app-preference-tab-registration"; import type { KubeObjectDetailRegistration } from "../renderer/components/kube-object-details/kube-object-detail-registration"; +import type { ClusterFrameChildComponent } from "../renderer/frames/cluster-frame/cluster-frame-child-component-injection-token"; export class LensRendererExtension extends LensExtension { globalPages: registries.PageRegistration[] = []; clusterPages: registries.PageRegistration[] = []; clusterPageMenus: registries.ClusterPageMenuRegistration[] = []; + clusterFrameComponents: ClusterFrameChildComponent[] = []; kubeObjectStatusTexts: KubeObjectStatusRegistration[] = []; appPreferences: AppPreferenceRegistration[] = []; appPreferenceTabs: AppPreferenceTabRegistration[] = []; diff --git a/src/extensions/npm/extensions/package-lock.json b/src/extensions/npm/extensions/package-lock.json deleted file mode 100644 index dd8bade7d9..0000000000 --- a/src/extensions/npm/extensions/package-lock.json +++ /dev/null @@ -1,2288 +0,0 @@ -{ - "name": "@k8slens/extensions", - "version": "0.0.1", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "@k8slens/extensions", - "version": "0.0.1", - "license": "MIT", - "dependencies": { - "@material-ui/core": "4.12.3", - "@types/node": "14.17.14", - "@types/react": "^17.0.45", - "@types/react-dom": "^17.0.16", - "@types/react-router": "^5.1.18", - "@types/react-router-dom": "^5.3.3", - "conf": "^7.0.1", - "mobx": "^6.5.0", - "mobx-react": "^7.3.0", - "react-select": "^5.3.2", - "typed-emitter": "^1.3.1" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", - "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", - "dependencies": { - "@babel/highlight": "^7.16.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz", - "integrity": "sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg==", - "dependencies": { - "@babel/types": "^7.16.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.17.12", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.17.12.tgz", - "integrity": "sha512-JDkf04mqtN3y4iAbO1hv9U2ARpPyPL1zqyWs/2WG1pgSq9llHFjStX5jdxb84himgJm+8Ng+x0oiWF/nw/XQKA==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", - "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.17.12", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.17.12.tgz", - "integrity": "sha512-7yykMVF3hfZY2jsHZEEgLc+3x4o1O+fYyULu11GynEUQNwB6lua+IIQn1FiJxNucd5UlyJryrwsOh8PL9Sn8Qg==", - "dependencies": { - "@babel/helper-validator-identifier": "^7.16.7", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.17.12", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.17.12.tgz", - "integrity": "sha512-spyY3E3AURfxh/RHtjx5j6hs8am5NbUBGfcZ2vB3uShSpZdQyXSf5rR5Mk76vbtlAZOelyVQ71Fg0x9SG4fsog==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.17.12" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.9.tgz", - "integrity": "sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==", - "dependencies": { - "regenerator-runtime": "^0.13.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.18.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.18.4.tgz", - "integrity": "sha512-ThN1mBcMq5pG/Vm2IcBmPPfyPXbd8S02rS+OBIDENdufvqC7Z/jHPCv9IcP01277aKtDI8g/2XysBN4hA8niiw==", - "dependencies": { - "@babel/helper-validator-identifier": "^7.16.7", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@emotion/babel-plugin": { - "version": "11.9.2", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.9.2.tgz", - "integrity": "sha512-Pr/7HGH6H6yKgnVFNEj2MVlreu3ADqftqjqwUvDy/OJzKFgxKeTQ+eeUf20FOTuHVkDON2iNa25rAXVYtWJCjw==", - "dependencies": { - "@babel/helper-module-imports": "^7.12.13", - "@babel/plugin-syntax-jsx": "^7.12.13", - "@babel/runtime": "^7.13.10", - "@emotion/hash": "^0.8.0", - "@emotion/memoize": "^0.7.5", - "@emotion/serialize": "^1.0.2", - "babel-plugin-macros": "^2.6.1", - "convert-source-map": "^1.5.0", - "escape-string-regexp": "^4.0.0", - "find-root": "^1.1.0", - "source-map": "^0.5.7", - "stylis": "4.0.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@emotion/cache": { - "version": "11.9.3", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.9.3.tgz", - "integrity": "sha512-0dgkI/JKlCXa+lEXviaMtGBL0ynpx4osh7rjOXE71q9bIF8G+XhJgvi+wDu0B0IdCVx37BffiwXlN9I3UuzFvg==", - "dependencies": { - "@emotion/memoize": "^0.7.4", - "@emotion/sheet": "^1.1.1", - "@emotion/utils": "^1.0.0", - "@emotion/weak-memoize": "^0.2.5", - "stylis": "4.0.13" - } - }, - "node_modules/@emotion/hash": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", - "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==" - }, - "node_modules/@emotion/memoize": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.5.tgz", - "integrity": "sha512-igX9a37DR2ZPGYtV6suZ6whr8pTFtyHL3K/oLUotxpSVO2ASaprmAe2Dkq7tBo7CRY7MMDrAa9nuQP9/YG8FxQ==" - }, - "node_modules/@emotion/react": { - "version": "11.9.3", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.9.3.tgz", - "integrity": "sha512-g9Q1GcTOlzOEjqwuLF/Zd9LC+4FljjPjDfxSM7KmEakm+hsHXk+bYZ2q+/hTJzr0OUNkujo72pXLQvXj6H+GJQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@emotion/babel-plugin": "^11.7.1", - "@emotion/cache": "^11.9.3", - "@emotion/serialize": "^1.0.4", - "@emotion/utils": "^1.1.0", - "@emotion/weak-memoize": "^0.2.5", - "hoist-non-react-statics": "^3.3.1" - }, - "peerDependencies": { - "@babel/core": "^7.0.0", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "@types/react": { - "optional": true - } - } - }, - "node_modules/@emotion/serialize": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.0.4.tgz", - "integrity": "sha512-1JHamSpH8PIfFwAMryO2bNka+y8+KA5yga5Ocf2d7ZEiJjb7xlLW7aknBGZqJLajuLOvJ+72vN+IBSwPlXD1Pg==", - "dependencies": { - "@emotion/hash": "^0.8.0", - "@emotion/memoize": "^0.7.4", - "@emotion/unitless": "^0.7.5", - "@emotion/utils": "^1.0.0", - "csstype": "^3.0.2" - } - }, - "node_modules/@emotion/serialize/node_modules/csstype": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.0.tgz", - "integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==" - }, - "node_modules/@emotion/sheet": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.1.1.tgz", - "integrity": "sha512-J3YPccVRMiTZxYAY0IOq3kd+hUP8idY8Kz6B/Cyo+JuXq52Ek+zbPbSQUrVQp95aJ+lsAW7DPL1P2Z+U1jGkKA==" - }, - "node_modules/@emotion/unitless": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", - "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==" - }, - "node_modules/@emotion/utils": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.1.0.tgz", - "integrity": "sha512-iRLa/Y4Rs5H/f2nimczYmS5kFJEbpiVvgN3XVfZ022IYhuNA1IRSHEizcof88LtCTXtl9S2Cxt32KgaXEu72JQ==" - }, - "node_modules/@emotion/weak-memoize": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz", - "integrity": "sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==" - }, - "node_modules/@material-ui/core": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.12.3.tgz", - "integrity": "sha512-sdpgI/PL56QVsEJldwEe4FFaFTLUqN+rd7sSZiRCdx2E/C7z5yK0y/khAWVBH24tXwto7I1hCzNWfJGZIYJKnw==", - "deprecated": "You can now upgrade to @mui/material. See the guide: https://mui.com/guides/migration-v4/", - "dependencies": { - "@babel/runtime": "^7.4.4", - "@material-ui/styles": "^4.11.4", - "@material-ui/system": "^4.12.1", - "@material-ui/types": "5.1.0", - "@material-ui/utils": "^4.11.2", - "@types/react-transition-group": "^4.2.0", - "clsx": "^1.0.4", - "hoist-non-react-statics": "^3.3.2", - "popper.js": "1.16.1-lts", - "prop-types": "^15.7.2", - "react-is": "^16.8.0 || ^17.0.0", - "react-transition-group": "^4.4.0" - }, - "engines": { - "node": ">=8.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/material-ui" - }, - "peerDependencies": { - "@types/react": "^16.8.6 || ^17.0.0", - "react": "^16.8.0 || ^17.0.0", - "react-dom": "^16.8.0 || ^17.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@material-ui/styles": { - "version": "4.11.5", - "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.5.tgz", - "integrity": "sha512-o/41ot5JJiUsIETME9wVLAJrmIWL3j0R0Bj2kCOLbSfqEkKf0fmaPt+5vtblUh5eXr2S+J/8J3DaCb10+CzPGA==", - "dependencies": { - "@babel/runtime": "^7.4.4", - "@emotion/hash": "^0.8.0", - "@material-ui/types": "5.1.0", - "@material-ui/utils": "^4.11.3", - "clsx": "^1.0.4", - "csstype": "^2.5.2", - "hoist-non-react-statics": "^3.3.2", - "jss": "^10.5.1", - "jss-plugin-camel-case": "^10.5.1", - "jss-plugin-default-unit": "^10.5.1", - "jss-plugin-global": "^10.5.1", - "jss-plugin-nested": "^10.5.1", - "jss-plugin-props-sort": "^10.5.1", - "jss-plugin-rule-value-function": "^10.5.1", - "jss-plugin-vendor-prefixer": "^10.5.1", - "prop-types": "^15.7.2" - }, - "engines": { - "node": ">=8.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/material-ui" - }, - "peerDependencies": { - "@types/react": "^16.8.6 || ^17.0.0", - "react": "^16.8.0 || ^17.0.0", - "react-dom": "^16.8.0 || ^17.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@material-ui/system": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.12.2.tgz", - "integrity": "sha512-6CSKu2MtmiJgcCGf6nBQpM8fLkuB9F55EKfbdTC80NND5wpTmKzwdhLYLH3zL4cLlK0gVaaltW7/wMuyTnN0Lw==", - "dependencies": { - "@babel/runtime": "^7.4.4", - "@material-ui/utils": "^4.11.3", - "csstype": "^2.5.2", - "prop-types": "^15.7.2" - }, - "engines": { - "node": ">=8.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/material-ui" - }, - "peerDependencies": { - "@types/react": "^16.8.6 || ^17.0.0", - "react": "^16.8.0 || ^17.0.0", - "react-dom": "^16.8.0 || ^17.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@material-ui/types": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz", - "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==", - "peerDependencies": { - "@types/react": "*" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@material-ui/utils": { - "version": "4.11.3", - "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.11.3.tgz", - "integrity": "sha512-ZuQPV4rBK/V1j2dIkSSEcH5uT6AaHuKWFfotADHsC0wVL1NLd2WkFCm4ZZbX33iO4ydl6V0GPngKm8HZQ2oujg==", - "dependencies": { - "@babel/runtime": "^7.4.4", - "prop-types": "^15.7.2", - "react-is": "^16.8.0 || ^17.0.0" - }, - "engines": { - "node": ">=8.0.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0", - "react-dom": "^16.8.0 || ^17.0.0" - } - }, - "node_modules/@types/history": { - "version": "4.7.11", - "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", - "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==" - }, - "node_modules/@types/node": { - "version": "14.17.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.14.tgz", - "integrity": "sha512-rsAj2u8Xkqfc332iXV12SqIsjVi07H479bOP4q94NAcjzmAvapumEhuVIt53koEf7JFrpjgNKjBga5Pnn/GL8A==" - }, - "node_modules/@types/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" - }, - "node_modules/@types/prop-types": { - "version": "15.7.5", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" - }, - "node_modules/@types/react": { - "version": "17.0.45", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.45.tgz", - "integrity": "sha512-YfhQ22Lah2e3CHPsb93tRwIGNiSwkuz1/blk4e6QrWS0jQzCSNbGLtOEYhPg02W0yGTTmpajp7dCTbBAMN3qsg==", - "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "17.0.16", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.16.tgz", - "integrity": "sha512-DWcXf8EbMrO/gWnQU7Z88Ws/p16qxGpPyjTKTpmBSFKeE+HveVubqGO1CVK7FrwlWD5MuOcvh8gtd0/XO38NdQ==", - "dependencies": { - "@types/react": "^17" - } - }, - "node_modules/@types/react-router": { - "version": "5.1.18", - "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.18.tgz", - "integrity": "sha512-YYknwy0D0iOwKQgz9v8nOzt2J6l4gouBmDnWqUUznltOTaon+r8US8ky8HvN0tXvc38U9m6z/t2RsVsnd1zM0g==", - "dependencies": { - "@types/history": "^4.7.11", - "@types/react": "*" - } - }, - "node_modules/@types/react-router-dom": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", - "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", - "dependencies": { - "@types/history": "^4.7.11", - "@types/react": "*", - "@types/react-router": "*" - } - }, - "node_modules/@types/react-transition-group": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.4.tgz", - "integrity": "sha512-7gAPz7anVK5xzbeQW9wFBDg7G++aPLAFY0QaSMOou9rJZpbuI58WAuJrgu+qR92l61grlnCUe7AFX8KGahAgug==", - "dependencies": { - "@types/react": "*" - } - }, - "node_modules/@types/react/node_modules/csstype": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", - "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" - }, - "node_modules/@types/scheduler": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/atomically": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/atomically/-/atomically-1.7.0.tgz", - "integrity": "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==", - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/babel-plugin-macros": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz", - "integrity": "sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==", - "dependencies": { - "@babel/runtime": "^7.7.2", - "cosmiconfig": "^6.0.0", - "resolve": "^1.12.0" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "engines": { - "node": ">=6" - } - }, - "node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/chalk/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/clsx": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", - "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "node_modules/conf": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/conf/-/conf-7.1.2.tgz", - "integrity": "sha512-r8/HEoWPFn4CztjhMJaWNAe5n+gPUCSaJ0oufbqDLFKsA1V8JjAG7G+p0pgoDFAws9Bpk2VtVLLXqOBA7WxLeg==", - "dependencies": { - "ajv": "^6.12.2", - "atomically": "^1.3.1", - "debounce-fn": "^4.0.0", - "dot-prop": "^5.2.0", - "env-paths": "^2.2.0", - "json-schema-typed": "^7.0.3", - "make-dir": "^3.1.0", - "onetime": "^5.1.0", - "pkg-up": "^3.1.0", - "semver": "^7.3.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/convert-source-map": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz", - "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==", - "dependencies": { - "safe-buffer": "~5.1.1" - } - }, - "node_modules/cosmiconfig": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", - "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.1.0", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.7.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/css-vendor": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz", - "integrity": "sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==", - "dependencies": { - "@babel/runtime": "^7.8.3", - "is-in-browser": "^1.0.2" - } - }, - "node_modules/csstype": { - "version": "2.6.20", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.20.tgz", - "integrity": "sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA==" - }, - "node_modules/debounce-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", - "integrity": "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==", - "dependencies": { - "mimic-fn": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, - "node_modules/dom-helpers/node_modules/csstype": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", - "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" - }, - "node_modules/dot-prop": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", - "dependencies": { - "is-obj": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "engines": { - "node": ">=6" - } - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" - }, - "node_modules/find-root": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" - }, - "node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dependencies": { - "locate-path": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" - }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "engines": { - "node": ">=4" - } - }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "dependencies": { - "react-is": "^16.7.0" - } - }, - "node_modules/hoist-non-react-statics/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - }, - "node_modules/hyphenate-style-name": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", - "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==" - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" - }, - "node_modules/is-core-module": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz", - "integrity": "sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==", - "dependencies": { - "has": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-in-browser": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", - "integrity": "sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU=" - }, - "node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "engines": { - "node": ">=8" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" - }, - "node_modules/json-schema-typed": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-7.0.3.tgz", - "integrity": "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==" - }, - "node_modules/jss": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/jss/-/jss-10.9.0.tgz", - "integrity": "sha512-YpzpreB6kUunQBbrlArlsMpXYyndt9JATbt95tajx0t4MTJJcCJdd4hdNpHmOIDiUJrF/oX5wtVFrS3uofWfGw==", - "dependencies": { - "@babel/runtime": "^7.3.1", - "csstype": "^3.0.2", - "is-in-browser": "^1.1.3", - "tiny-warning": "^1.0.2" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/jss" - } - }, - "node_modules/jss-plugin-camel-case": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.9.0.tgz", - "integrity": "sha512-UH6uPpnDk413/r/2Olmw4+y54yEF2lRIV8XIZyuYpgPYTITLlPOsq6XB9qeqv+75SQSg3KLocq5jUBXW8qWWww==", - "dependencies": { - "@babel/runtime": "^7.3.1", - "hyphenate-style-name": "^1.0.3", - "jss": "10.9.0" - } - }, - "node_modules/jss-plugin-default-unit": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.9.0.tgz", - "integrity": "sha512-7Ju4Q9wJ/MZPsxfu4T84mzdn7pLHWeqoGd/D8O3eDNNJ93Xc8PxnLmV8s8ZPNRYkLdxZqKtm1nPQ0BM4JRlq2w==", - "dependencies": { - "@babel/runtime": "^7.3.1", - "jss": "10.9.0" - } - }, - "node_modules/jss-plugin-global": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.9.0.tgz", - "integrity": "sha512-4G8PHNJ0x6nwAFsEzcuVDiBlyMsj2y3VjmFAx/uHk/R/gzJV+yRHICjT4MKGGu1cJq2hfowFWCyrr/Gg37FbgQ==", - "dependencies": { - "@babel/runtime": "^7.3.1", - "jss": "10.9.0" - } - }, - "node_modules/jss-plugin-nested": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.9.0.tgz", - "integrity": "sha512-2UJnDrfCZpMYcpPYR16oZB7VAC6b/1QLsRiAutOt7wJaaqwCBvNsosLEu/fUyKNQNGdvg2PPJFDO5AX7dwxtoA==", - "dependencies": { - "@babel/runtime": "^7.3.1", - "jss": "10.9.0", - "tiny-warning": "^1.0.2" - } - }, - "node_modules/jss-plugin-props-sort": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.9.0.tgz", - "integrity": "sha512-7A76HI8bzwqrsMOJTWKx/uD5v+U8piLnp5bvru7g/3ZEQOu1+PjHvv7bFdNO3DwNPC9oM0a//KwIJsIcDCjDzw==", - "dependencies": { - "@babel/runtime": "^7.3.1", - "jss": "10.9.0" - } - }, - "node_modules/jss-plugin-rule-value-function": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.9.0.tgz", - "integrity": "sha512-IHJv6YrEf8pRzkY207cPmdbBstBaE+z8pazhPShfz0tZSDtRdQua5jjg6NMz3IbTasVx9FdnmptxPqSWL5tyJg==", - "dependencies": { - "@babel/runtime": "^7.3.1", - "jss": "10.9.0", - "tiny-warning": "^1.0.2" - } - }, - "node_modules/jss-plugin-vendor-prefixer": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.9.0.tgz", - "integrity": "sha512-MbvsaXP7iiVdYVSEoi+blrW+AYnTDvHTW6I6zqi7JcwXdc6I9Kbm234nEblayhF38EftoenbM+5218pidmC5gA==", - "dependencies": { - "@babel/runtime": "^7.3.1", - "css-vendor": "^2.0.8", - "jss": "10.9.0" - } - }, - "node_modules/jss/node_modules/csstype": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", - "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" - }, - "node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/memoize-one": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", - "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" - }, - "node_modules/mimic-fn": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", - "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/mobx": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.5.0.tgz", - "integrity": "sha512-pHZ/cySF00FVENDWIDzJyoObFahK6Eg4d0papqm6d7yMkxWTZ/S/csqJX1A3PsYy4t5k3z2QnlwuCfMW5lSEwA==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mobx" - } - }, - "node_modules/mobx-react": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/mobx-react/-/mobx-react-7.4.0.tgz", - "integrity": "sha512-gbUwaKZK09SiAleTMxNMKs1MYKTpoIEWJLTLRIR/xnALuuHET8wkL8j1nbc1/6cDkOWVyKz/ReftILx0Pdh2PQ==", - "dependencies": { - "mobx-react-lite": "^3.4.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mobx" - }, - "peerDependencies": { - "mobx": "^6.1.0", - "react": "^16.8.0 || ^17 || ^18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, - "node_modules/mobx-react-lite": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-3.4.0.tgz", - "integrity": "sha512-bRuZp3C0itgLKHu/VNxi66DN/XVkQG7xtoBVWxpvC5FhAqbOCP21+nPhULjnzEqd7xBMybp6KwytdUpZKEgpIQ==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mobx" - }, - "peerDependencies": { - "mobx": "^6.1.0", - "react": "^16.8.0 || ^17 || ^18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/onetime/node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dependencies": { - "p-limit": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "engines": { - "node": ">=6" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", - "engines": { - "node": ">=4" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-up": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", - "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", - "dependencies": { - "find-up": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/popper.js": { - "version": "1.16.1-lts", - "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1-lts.tgz", - "integrity": "sha512-Kjw8nKRl1m+VrSFCoVGPph93W/qrSO7ZkqPpTf7F4bk/sqcfWK019dWBUpE/fBOsOQY1dks/Bmcbfn1heM/IsA==" - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - }, - "node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "engines": { - "node": ">=6" - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" - }, - "node_modules/react-select": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.3.2.tgz", - "integrity": "sha512-W6Irh7U6Ha7p5uQQ2ZnemoCQ8mcfgOtHfw3wuMzG6FAu0P+CYicgofSLOq97BhjMx8jS+h+wwWdCBeVVZ9VqlQ==", - "dependencies": { - "@babel/runtime": "^7.12.0", - "@emotion/cache": "^11.4.0", - "@emotion/react": "^11.8.1", - "@types/react-transition-group": "^4.4.0", - "memoize-one": "^5.0.0", - "prop-types": "^15.6.0", - "react-transition-group": "^4.3.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/react-transition-group": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz", - "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==", - "dependencies": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" - } - }, - "node_modules/regenerator-runtime": { - "version": "0.13.9", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", - "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==" - }, - "node_modules/resolve": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", - "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", - "dependencies": { - "is-core-module": "^2.9.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "engines": { - "node": ">=4" - } - }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "node_modules/semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stylis": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.0.13.tgz", - "integrity": "sha512-xGPXiFVl4YED9Jh7Euv2V220mriG9u4B2TA6Ybjc1catrstKD2PpIdU3U0RKpkVBC2EhmL/F0sPCr9vrFTNRag==" - }, - "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tiny-warning": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" - }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "engines": { - "node": ">=4" - } - }, - "node_modules/typed-emitter": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-1.4.0.tgz", - "integrity": "sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg==" - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, - "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "engines": { - "node": ">= 6" - } - } - }, - "dependencies": { - "@babel/code-frame": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", - "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", - "requires": { - "@babel/highlight": "^7.16.7" - } - }, - "@babel/helper-module-imports": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz", - "integrity": "sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg==", - "requires": { - "@babel/types": "^7.16.7" - } - }, - "@babel/helper-plugin-utils": { - "version": "7.17.12", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.17.12.tgz", - "integrity": "sha512-JDkf04mqtN3y4iAbO1hv9U2ARpPyPL1zqyWs/2WG1pgSq9llHFjStX5jdxb84himgJm+8Ng+x0oiWF/nw/XQKA==" - }, - "@babel/helper-validator-identifier": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", - "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==" - }, - "@babel/highlight": { - "version": "7.17.12", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.17.12.tgz", - "integrity": "sha512-7yykMVF3hfZY2jsHZEEgLc+3x4o1O+fYyULu11GynEUQNwB6lua+IIQn1FiJxNucd5UlyJryrwsOh8PL9Sn8Qg==", - "requires": { - "@babel/helper-validator-identifier": "^7.16.7", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" - } - }, - "@babel/plugin-syntax-jsx": { - "version": "7.17.12", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.17.12.tgz", - "integrity": "sha512-spyY3E3AURfxh/RHtjx5j6hs8am5NbUBGfcZ2vB3uShSpZdQyXSf5rR5Mk76vbtlAZOelyVQ71Fg0x9SG4fsog==", - "requires": { - "@babel/helper-plugin-utils": "^7.17.12" - } - }, - "@babel/runtime": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.9.tgz", - "integrity": "sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==", - "requires": { - "regenerator-runtime": "^0.13.4" - } - }, - "@babel/types": { - "version": "7.18.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.18.4.tgz", - "integrity": "sha512-ThN1mBcMq5pG/Vm2IcBmPPfyPXbd8S02rS+OBIDENdufvqC7Z/jHPCv9IcP01277aKtDI8g/2XysBN4hA8niiw==", - "requires": { - "@babel/helper-validator-identifier": "^7.16.7", - "to-fast-properties": "^2.0.0" - } - }, - "@emotion/babel-plugin": { - "version": "11.9.2", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.9.2.tgz", - "integrity": "sha512-Pr/7HGH6H6yKgnVFNEj2MVlreu3ADqftqjqwUvDy/OJzKFgxKeTQ+eeUf20FOTuHVkDON2iNa25rAXVYtWJCjw==", - "requires": { - "@babel/helper-module-imports": "^7.12.13", - "@babel/plugin-syntax-jsx": "^7.12.13", - "@babel/runtime": "^7.13.10", - "@emotion/hash": "^0.8.0", - "@emotion/memoize": "^0.7.5", - "@emotion/serialize": "^1.0.2", - "babel-plugin-macros": "^2.6.1", - "convert-source-map": "^1.5.0", - "escape-string-regexp": "^4.0.0", - "find-root": "^1.1.0", - "source-map": "^0.5.7", - "stylis": "4.0.13" - } - }, - "@emotion/cache": { - "version": "11.9.3", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.9.3.tgz", - "integrity": "sha512-0dgkI/JKlCXa+lEXviaMtGBL0ynpx4osh7rjOXE71q9bIF8G+XhJgvi+wDu0B0IdCVx37BffiwXlN9I3UuzFvg==", - "requires": { - "@emotion/memoize": "^0.7.4", - "@emotion/sheet": "^1.1.1", - "@emotion/utils": "^1.0.0", - "@emotion/weak-memoize": "^0.2.5", - "stylis": "4.0.13" - } - }, - "@emotion/hash": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", - "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==" - }, - "@emotion/memoize": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.5.tgz", - "integrity": "sha512-igX9a37DR2ZPGYtV6suZ6whr8pTFtyHL3K/oLUotxpSVO2ASaprmAe2Dkq7tBo7CRY7MMDrAa9nuQP9/YG8FxQ==" - }, - "@emotion/react": { - "version": "11.9.3", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.9.3.tgz", - "integrity": "sha512-g9Q1GcTOlzOEjqwuLF/Zd9LC+4FljjPjDfxSM7KmEakm+hsHXk+bYZ2q+/hTJzr0OUNkujo72pXLQvXj6H+GJQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@emotion/babel-plugin": "^11.7.1", - "@emotion/cache": "^11.9.3", - "@emotion/serialize": "^1.0.4", - "@emotion/utils": "^1.1.0", - "@emotion/weak-memoize": "^0.2.5", - "hoist-non-react-statics": "^3.3.1" - } - }, - "@emotion/serialize": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.0.4.tgz", - "integrity": "sha512-1JHamSpH8PIfFwAMryO2bNka+y8+KA5yga5Ocf2d7ZEiJjb7xlLW7aknBGZqJLajuLOvJ+72vN+IBSwPlXD1Pg==", - "requires": { - "@emotion/hash": "^0.8.0", - "@emotion/memoize": "^0.7.4", - "@emotion/unitless": "^0.7.5", - "@emotion/utils": "^1.0.0", - "csstype": "^3.0.2" - }, - "dependencies": { - "csstype": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.0.tgz", - "integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==" - } - } - }, - "@emotion/sheet": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.1.1.tgz", - "integrity": "sha512-J3YPccVRMiTZxYAY0IOq3kd+hUP8idY8Kz6B/Cyo+JuXq52Ek+zbPbSQUrVQp95aJ+lsAW7DPL1P2Z+U1jGkKA==" - }, - "@emotion/unitless": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", - "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==" - }, - "@emotion/utils": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.1.0.tgz", - "integrity": "sha512-iRLa/Y4Rs5H/f2nimczYmS5kFJEbpiVvgN3XVfZ022IYhuNA1IRSHEizcof88LtCTXtl9S2Cxt32KgaXEu72JQ==" - }, - "@emotion/weak-memoize": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz", - "integrity": "sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==" - }, - "@material-ui/core": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.12.3.tgz", - "integrity": "sha512-sdpgI/PL56QVsEJldwEe4FFaFTLUqN+rd7sSZiRCdx2E/C7z5yK0y/khAWVBH24tXwto7I1hCzNWfJGZIYJKnw==", - "requires": { - "@babel/runtime": "^7.4.4", - "@material-ui/styles": "^4.11.4", - "@material-ui/system": "^4.12.1", - "@material-ui/types": "5.1.0", - "@material-ui/utils": "^4.11.2", - "@types/react-transition-group": "^4.2.0", - "clsx": "^1.0.4", - "hoist-non-react-statics": "^3.3.2", - "popper.js": "1.16.1-lts", - "prop-types": "^15.7.2", - "react-is": "^16.8.0 || ^17.0.0", - "react-transition-group": "^4.4.0" - } - }, - "@material-ui/styles": { - "version": "4.11.5", - "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.5.tgz", - "integrity": "sha512-o/41ot5JJiUsIETME9wVLAJrmIWL3j0R0Bj2kCOLbSfqEkKf0fmaPt+5vtblUh5eXr2S+J/8J3DaCb10+CzPGA==", - "requires": { - "@babel/runtime": "^7.4.4", - "@emotion/hash": "^0.8.0", - "@material-ui/types": "5.1.0", - "@material-ui/utils": "^4.11.3", - "clsx": "^1.0.4", - "csstype": "^2.5.2", - "hoist-non-react-statics": "^3.3.2", - "jss": "^10.5.1", - "jss-plugin-camel-case": "^10.5.1", - "jss-plugin-default-unit": "^10.5.1", - "jss-plugin-global": "^10.5.1", - "jss-plugin-nested": "^10.5.1", - "jss-plugin-props-sort": "^10.5.1", - "jss-plugin-rule-value-function": "^10.5.1", - "jss-plugin-vendor-prefixer": "^10.5.1", - "prop-types": "^15.7.2" - } - }, - "@material-ui/system": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.12.2.tgz", - "integrity": "sha512-6CSKu2MtmiJgcCGf6nBQpM8fLkuB9F55EKfbdTC80NND5wpTmKzwdhLYLH3zL4cLlK0gVaaltW7/wMuyTnN0Lw==", - "requires": { - "@babel/runtime": "^7.4.4", - "@material-ui/utils": "^4.11.3", - "csstype": "^2.5.2", - "prop-types": "^15.7.2" - } - }, - "@material-ui/types": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz", - "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==", - "requires": {} - }, - "@material-ui/utils": { - "version": "4.11.3", - "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.11.3.tgz", - "integrity": "sha512-ZuQPV4rBK/V1j2dIkSSEcH5uT6AaHuKWFfotADHsC0wVL1NLd2WkFCm4ZZbX33iO4ydl6V0GPngKm8HZQ2oujg==", - "requires": { - "@babel/runtime": "^7.4.4", - "prop-types": "^15.7.2", - "react-is": "^16.8.0 || ^17.0.0" - } - }, - "@types/history": { - "version": "4.7.11", - "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", - "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==" - }, - "@types/node": { - "version": "14.17.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.14.tgz", - "integrity": "sha512-rsAj2u8Xkqfc332iXV12SqIsjVi07H479bOP4q94NAcjzmAvapumEhuVIt53koEf7JFrpjgNKjBga5Pnn/GL8A==" - }, - "@types/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" - }, - "@types/prop-types": { - "version": "15.7.5", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" - }, - "@types/react": { - "version": "17.0.45", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.45.tgz", - "integrity": "sha512-YfhQ22Lah2e3CHPsb93tRwIGNiSwkuz1/blk4e6QrWS0jQzCSNbGLtOEYhPg02W0yGTTmpajp7dCTbBAMN3qsg==", - "requires": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - }, - "dependencies": { - "csstype": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", - "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" - } - } - }, - "@types/react-dom": { - "version": "17.0.16", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.16.tgz", - "integrity": "sha512-DWcXf8EbMrO/gWnQU7Z88Ws/p16qxGpPyjTKTpmBSFKeE+HveVubqGO1CVK7FrwlWD5MuOcvh8gtd0/XO38NdQ==", - "requires": { - "@types/react": "^17" - } - }, - "@types/react-router": { - "version": "5.1.18", - "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.18.tgz", - "integrity": "sha512-YYknwy0D0iOwKQgz9v8nOzt2J6l4gouBmDnWqUUznltOTaon+r8US8ky8HvN0tXvc38U9m6z/t2RsVsnd1zM0g==", - "requires": { - "@types/history": "^4.7.11", - "@types/react": "*" - } - }, - "@types/react-router-dom": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", - "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", - "requires": { - "@types/history": "^4.7.11", - "@types/react": "*", - "@types/react-router": "*" - } - }, - "@types/react-transition-group": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.4.tgz", - "integrity": "sha512-7gAPz7anVK5xzbeQW9wFBDg7G++aPLAFY0QaSMOou9rJZpbuI58WAuJrgu+qR92l61grlnCUe7AFX8KGahAgug==", - "requires": { - "@types/react": "*" - } - }, - "@types/scheduler": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" - }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "requires": { - "color-convert": "^1.9.0" - } - }, - "atomically": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/atomically/-/atomically-1.7.0.tgz", - "integrity": "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==" - }, - "babel-plugin-macros": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz", - "integrity": "sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==", - "requires": { - "@babel/runtime": "^7.7.2", - "cosmiconfig": "^6.0.0", - "resolve": "^1.12.0" - } - }, - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "dependencies": { - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" - } - } - }, - "clsx": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", - "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==" - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "conf": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/conf/-/conf-7.1.2.tgz", - "integrity": "sha512-r8/HEoWPFn4CztjhMJaWNAe5n+gPUCSaJ0oufbqDLFKsA1V8JjAG7G+p0pgoDFAws9Bpk2VtVLLXqOBA7WxLeg==", - "requires": { - "ajv": "^6.12.2", - "atomically": "^1.3.1", - "debounce-fn": "^4.0.0", - "dot-prop": "^5.2.0", - "env-paths": "^2.2.0", - "json-schema-typed": "^7.0.3", - "make-dir": "^3.1.0", - "onetime": "^5.1.0", - "pkg-up": "^3.1.0", - "semver": "^7.3.2" - } - }, - "convert-source-map": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz", - "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==", - "requires": { - "safe-buffer": "~5.1.1" - } - }, - "cosmiconfig": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", - "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", - "requires": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.1.0", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.7.2" - } - }, - "css-vendor": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz", - "integrity": "sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==", - "requires": { - "@babel/runtime": "^7.8.3", - "is-in-browser": "^1.0.2" - } - }, - "csstype": { - "version": "2.6.20", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.20.tgz", - "integrity": "sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA==" - }, - "debounce-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", - "integrity": "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==", - "requires": { - "mimic-fn": "^3.0.0" - } - }, - "dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "requires": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - }, - "dependencies": { - "csstype": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", - "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" - } - } - }, - "dot-prop": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", - "requires": { - "is-obj": "^2.0.0" - } - }, - "env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==" - }, - "error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "requires": { - "is-arrayish": "^0.2.1" - } - }, - "escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" - }, - "find-root": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" - }, - "find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "requires": { - "locate-path": "^3.0.0" - } - }, - "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" - }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "requires": { - "function-bind": "^1.1.1" - } - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" - }, - "hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "requires": { - "react-is": "^16.7.0" - }, - "dependencies": { - "react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - } - } - }, - "hyphenate-style-name": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", - "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==" - }, - "import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "requires": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - } - }, - "is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" - }, - "is-core-module": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz", - "integrity": "sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==", - "requires": { - "has": "^1.0.3" - } - }, - "is-in-browser": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", - "integrity": "sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU=" - }, - "is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==" - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" - }, - "json-schema-typed": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-7.0.3.tgz", - "integrity": "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==" - }, - "jss": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/jss/-/jss-10.9.0.tgz", - "integrity": "sha512-YpzpreB6kUunQBbrlArlsMpXYyndt9JATbt95tajx0t4MTJJcCJdd4hdNpHmOIDiUJrF/oX5wtVFrS3uofWfGw==", - "requires": { - "@babel/runtime": "^7.3.1", - "csstype": "^3.0.2", - "is-in-browser": "^1.1.3", - "tiny-warning": "^1.0.2" - }, - "dependencies": { - "csstype": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", - "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" - } - } - }, - "jss-plugin-camel-case": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.9.0.tgz", - "integrity": "sha512-UH6uPpnDk413/r/2Olmw4+y54yEF2lRIV8XIZyuYpgPYTITLlPOsq6XB9qeqv+75SQSg3KLocq5jUBXW8qWWww==", - "requires": { - "@babel/runtime": "^7.3.1", - "hyphenate-style-name": "^1.0.3", - "jss": "10.9.0" - } - }, - "jss-plugin-default-unit": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.9.0.tgz", - "integrity": "sha512-7Ju4Q9wJ/MZPsxfu4T84mzdn7pLHWeqoGd/D8O3eDNNJ93Xc8PxnLmV8s8ZPNRYkLdxZqKtm1nPQ0BM4JRlq2w==", - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.9.0" - } - }, - "jss-plugin-global": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.9.0.tgz", - "integrity": "sha512-4G8PHNJ0x6nwAFsEzcuVDiBlyMsj2y3VjmFAx/uHk/R/gzJV+yRHICjT4MKGGu1cJq2hfowFWCyrr/Gg37FbgQ==", - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.9.0" - } - }, - "jss-plugin-nested": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.9.0.tgz", - "integrity": "sha512-2UJnDrfCZpMYcpPYR16oZB7VAC6b/1QLsRiAutOt7wJaaqwCBvNsosLEu/fUyKNQNGdvg2PPJFDO5AX7dwxtoA==", - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.9.0", - "tiny-warning": "^1.0.2" - } - }, - "jss-plugin-props-sort": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.9.0.tgz", - "integrity": "sha512-7A76HI8bzwqrsMOJTWKx/uD5v+U8piLnp5bvru7g/3ZEQOu1+PjHvv7bFdNO3DwNPC9oM0a//KwIJsIcDCjDzw==", - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.9.0" - } - }, - "jss-plugin-rule-value-function": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.9.0.tgz", - "integrity": "sha512-IHJv6YrEf8pRzkY207cPmdbBstBaE+z8pazhPShfz0tZSDtRdQua5jjg6NMz3IbTasVx9FdnmptxPqSWL5tyJg==", - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.9.0", - "tiny-warning": "^1.0.2" - } - }, - "jss-plugin-vendor-prefixer": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.9.0.tgz", - "integrity": "sha512-MbvsaXP7iiVdYVSEoi+blrW+AYnTDvHTW6I6zqi7JcwXdc6I9Kbm234nEblayhF38EftoenbM+5218pidmC5gA==", - "requires": { - "@babel/runtime": "^7.3.1", - "css-vendor": "^2.0.8", - "jss": "10.9.0" - } - }, - "lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" - }, - "locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "requires": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - } - }, - "loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "requires": { - "js-tokens": "^3.0.0 || ^4.0.0" - } - }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "requires": { - "yallist": "^4.0.0" - } - }, - "make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "requires": { - "semver": "^6.0.0" - }, - "dependencies": { - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" - } - } - }, - "memoize-one": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", - "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" - }, - "mimic-fn": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", - "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==" - }, - "mobx": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.5.0.tgz", - "integrity": "sha512-pHZ/cySF00FVENDWIDzJyoObFahK6Eg4d0papqm6d7yMkxWTZ/S/csqJX1A3PsYy4t5k3z2QnlwuCfMW5lSEwA==" - }, - "mobx-react": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/mobx-react/-/mobx-react-7.4.0.tgz", - "integrity": "sha512-gbUwaKZK09SiAleTMxNMKs1MYKTpoIEWJLTLRIR/xnALuuHET8wkL8j1nbc1/6cDkOWVyKz/ReftILx0Pdh2PQ==", - "requires": { - "mobx-react-lite": "^3.4.0" - } - }, - "mobx-react-lite": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-3.4.0.tgz", - "integrity": "sha512-bRuZp3C0itgLKHu/VNxi66DN/XVkQG7xtoBVWxpvC5FhAqbOCP21+nPhULjnzEqd7xBMybp6KwytdUpZKEgpIQ==", - "requires": {} - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" - }, - "onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "requires": { - "mimic-fn": "^2.1.0" - }, - "dependencies": { - "mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" - } - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "requires": { - "p-limit": "^2.0.0" - } - }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" - }, - "parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "requires": { - "callsites": "^3.0.0" - } - }, - "parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "requires": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - } - }, - "path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=" - }, - "path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" - }, - "path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" - }, - "pkg-up": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", - "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", - "requires": { - "find-up": "^3.0.0" - } - }, - "popper.js": { - "version": "1.16.1-lts", - "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1-lts.tgz", - "integrity": "sha512-Kjw8nKRl1m+VrSFCoVGPph93W/qrSO7ZkqPpTf7F4bk/sqcfWK019dWBUpE/fBOsOQY1dks/Bmcbfn1heM/IsA==" - }, - "prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "requires": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - }, - "dependencies": { - "react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - } - } - }, - "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" - }, - "react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" - }, - "react-select": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.3.2.tgz", - "integrity": "sha512-W6Irh7U6Ha7p5uQQ2ZnemoCQ8mcfgOtHfw3wuMzG6FAu0P+CYicgofSLOq97BhjMx8jS+h+wwWdCBeVVZ9VqlQ==", - "requires": { - "@babel/runtime": "^7.12.0", - "@emotion/cache": "^11.4.0", - "@emotion/react": "^11.8.1", - "@types/react-transition-group": "^4.4.0", - "memoize-one": "^5.0.0", - "prop-types": "^15.6.0", - "react-transition-group": "^4.3.0" - } - }, - "react-transition-group": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz", - "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==", - "requires": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - } - }, - "regenerator-runtime": { - "version": "0.13.9", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", - "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==" - }, - "resolve": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", - "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", - "requires": { - "is-core-module": "^2.9.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, - "resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", - "requires": { - "lru-cache": "^6.0.0" - } - }, - "source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==" - }, - "stylis": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.0.13.tgz", - "integrity": "sha512-xGPXiFVl4YED9Jh7Euv2V220mriG9u4B2TA6Ybjc1catrstKD2PpIdU3U0RKpkVBC2EhmL/F0sPCr9vrFTNRag==" - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "requires": { - "has-flag": "^3.0.0" - } - }, - "supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" - }, - "tiny-warning": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" - }, - "to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==" - }, - "typed-emitter": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-1.4.0.tgz", - "integrity": "sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg==" - }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "requires": { - "punycode": "^2.1.0" - } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, - "yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" - } - } -} diff --git a/src/extensions/npm/extensions/package.json b/src/extensions/npm/extensions/package.json index 2afda96cf2..cd59286da0 100644 --- a/src/extensions/npm/extensions/package.json +++ b/src/extensions/npm/extensions/package.json @@ -2,7 +2,7 @@ "name": "@k8slens/extensions", "productName": "OpenLens extensions", "description": "OpenLens - Open Source Kubernetes IDE: extensions", - "version": "0.0.1", + "version": "6.0.0", "copyright": "Š 2022 OpenLens Authors", "license": "MIT", "main": "dist/src/extensions/extension-api.js", @@ -16,8 +16,8 @@ "name": "OpenLens Authors" }, "dependencies": { - "@material-ui/core": "4.12.3", - "@types/node": "14.17.14", + "@material-ui/core": "^4.12.3", + "@types/node": "^16.11.58", "@types/react": "^17.0.45", "@types/react-dom": "^17.0.16", "@types/react-router": "^5.1.18", diff --git a/src/features/__snapshots__/extension-special-characters-in-page-registrations.test.tsx.snap b/src/features/__snapshots__/extension-special-characters-in-page-registrations.test.tsx.snap index 6fff5572d7..d755fa45fa 100644 --- a/src/features/__snapshots__/extension-special-characters-in-page-registrations.test.tsx.snap +++ b/src/features/__snapshots__/extension-special-characters-in-page-registrations.test.tsx.snap @@ -11,44 +11,61 @@ exports[`extension special characters in page registrations renders 1`] = `

- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -183,6 +200,7 @@ exports[`extension special characters in page registrations renders 1`] = `

- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-
- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -183,6 +200,7 @@ exports[`navigate to extension page renders 1`] = `

- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-
- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-
- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-
- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-
- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-
- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-
- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -183,6 +200,7 @@ exports[`add-cluster - navigation using application menu renders 1`] = `

- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-
{ describe("when navigating to add cluster using application menu", () => { beforeEach(async () => { - await applicationBuilder.applicationMenu.click("file.add-cluster"); + await applicationBuilder.applicationMenu.click( + "root", + "file", + "add-cluster", + ); }); it("renders", () => { diff --git a/src/features/application-menu/__snapshots__/application-menu.test.ts.snap b/src/features/application-menu/__snapshots__/application-menu.test.ts.snap new file mode 100644 index 0000000000..25ee762fea --- /dev/null +++ b/src/features/application-menu/__snapshots__/application-menu.test.ts.snap @@ -0,0 +1,132 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`application-menu, given platform is 'darwin' given enough time passes populates application menu 1`] = ` +Array [ + "root", + "root -> mac", + "root -> mac -> about", + "root -> mac -> separator-1", + "root -> mac -> navigate-to-preferences", + "root -> mac -> navigate-to-extensions", + "root -> mac -> separator-2", + "root -> mac -> services", + "root -> mac -> separator-3", + "root -> mac -> hide", + "root -> mac -> hide-others", + "root -> mac -> unhide", + "root -> mac -> separator-4", + "root -> mac -> quit", + "root -> file", + "root -> file -> add-cluster", + "root -> file -> separator-1-for-file", + "root -> file -> close-window", + "root -> edit", + "root -> edit -> undo", + "root -> edit -> redo", + "root -> edit -> separator-1-in-edit", + "root -> edit -> cut", + "root -> edit -> copy", + "root -> edit -> paste", + "root -> edit -> delete", + "root -> edit -> separator-2-in-edit", + "root -> edit -> selectAll", + "root -> view", + "root -> view -> navigate-to-catalog", + "root -> view -> open-command-palette", + "root -> view -> separator-1-for-view", + "root -> view -> go-back", + "root -> view -> go-forward", + "root -> view -> reload", + "root -> view -> toggle-dev-tools", + "root -> view -> separator-2-for-view", + "root -> view -> reset-zoom", + "root -> view -> zoom-in", + "root -> view -> zoom-out", + "root -> view -> separator-3-for-view", + "root -> view -> toggle-full-screen", + "root -> help", + "root -> help -> navigate-to-welcome", + "root -> help -> open-documentation", + "root -> help -> open-support", +] +`; + +exports[`application-menu, given platform is 'linux' given enough time passes populates application menu 1`] = ` +Array [ + "root", + "root -> file", + "root -> file -> add-cluster", + "root -> file -> navigate-to-preferences", + "root -> file -> navigate-to-extensions", + "root -> file -> quit", + "root -> edit", + "root -> edit -> undo", + "root -> edit -> redo", + "root -> edit -> separator-1-in-edit", + "root -> edit -> cut", + "root -> edit -> copy", + "root -> edit -> paste", + "root -> edit -> delete", + "root -> edit -> separator-2-in-edit", + "root -> edit -> selectAll", + "root -> view", + "root -> view -> navigate-to-catalog", + "root -> view -> open-command-palette", + "root -> view -> separator-1-for-view", + "root -> view -> go-back", + "root -> view -> go-forward", + "root -> view -> reload", + "root -> view -> toggle-dev-tools", + "root -> view -> separator-2-for-view", + "root -> view -> reset-zoom", + "root -> view -> zoom-in", + "root -> view -> zoom-out", + "root -> view -> separator-3-for-view", + "root -> view -> toggle-full-screen", + "root -> help", + "root -> help -> navigate-to-welcome", + "root -> help -> open-documentation", + "root -> help -> open-support", + "root -> help -> about", +] +`; + +exports[`application-menu, given platform is 'win32' given enough time passes populates application menu 1`] = ` +Array [ + "root", + "root -> file", + "root -> file -> add-cluster", + "root -> file -> navigate-to-preferences", + "root -> file -> navigate-to-extensions", + "root -> file -> quit", + "root -> edit", + "root -> edit -> undo", + "root -> edit -> redo", + "root -> edit -> separator-1-in-edit", + "root -> edit -> cut", + "root -> edit -> copy", + "root -> edit -> paste", + "root -> edit -> delete", + "root -> edit -> separator-2-in-edit", + "root -> edit -> selectAll", + "root -> view", + "root -> view -> navigate-to-catalog", + "root -> view -> open-command-palette", + "root -> view -> separator-1-for-view", + "root -> view -> go-back", + "root -> view -> go-forward", + "root -> view -> reload", + "root -> view -> toggle-dev-tools", + "root -> view -> separator-2-for-view", + "root -> view -> reset-zoom", + "root -> view -> zoom-in", + "root -> view -> zoom-out", + "root -> view -> separator-3-for-view", + "root -> view -> toggle-full-screen", + "root -> help", + "root -> help -> navigate-to-welcome", + "root -> help -> open-documentation", + "root -> help -> open-support", + "root -> help -> about", +] +`; diff --git a/src/features/application-menu/application-menu-in-legacy-extension-api.test.ts b/src/features/application-menu/application-menu-in-legacy-extension-api.test.ts new file mode 100644 index 0000000000..888f89f251 --- /dev/null +++ b/src/features/application-menu/application-menu-in-legacy-extension-api.test.ts @@ -0,0 +1,226 @@ +/** + * 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 { noop } from "lodash/fp"; +import { runInAction } from "mobx"; +import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import type { FakeExtensionOptions } from "../../renderer/components/test-utils/get-extension-fake"; +import applicationMenuItemInjectionToken from "./main/menu-items/application-menu-item-injection-token"; +import logErrorInjectable from "../../common/log-error.injectable"; + +describe("application-menu-in-legacy-extension-api", () => { + let builder: ApplicationBuilder; + let logErrorMock: jest.Mock; + + beforeEach(async () => { + builder = getApplicationBuilder(); + + builder.beforeApplicationStart( + (mainDi) => { + runInAction(() => { + mainDi.register( + someTopMenuItemInjectable, + someNonExtensionBasedMenuItemInjectable, + ); + }); + + logErrorMock = jest.fn(); + + mainDi.override(logErrorInjectable, () => logErrorMock); + }, + ); + + await builder.startHidden(); + }); + + describe("when extension with application menu items is enabled", () => { + let onClickMock: jest.Mock; + let testExtensionOptions: FakeExtensionOptions; + + beforeEach(() => { + onClickMock = jest.fn(); + + testExtensionOptions = { + id: "some-test-extension", + name: "some-extension-name", + + mainOptions: { + appMenus: [ + { + id: "some-non-shown-item", + parentId: "some-top-menu-item", + click: noop, + label: "Irrelevant", + visible: false, + }, + + { + id: "some-clickable-item", + parentId: "some-top-menu-item", + click: onClickMock, + }, + + { + parentId: "some-top-menu-item", + type: "separator", + }, + + { + id: "some-os-action-menu-item-id", + parentId: "some-top-menu-item", + role: "help", + }, + + { + id: "some-submenu-with-explicit-children", + parentId: "some-top-menu-item", + + submenu: [ + { id: "some-explicit-child", label: "Some explicit child", click: noop }, + ], + }, + ], + }, + }; + + builder.extensions.enable(testExtensionOptions); + }); + + it("related menu items exist", () => { + const menuItemPathsForExtension = builder.applicationMenu.items.filter( + (x) => + x.join(".").startsWith("root.some-top-menu-item.some-extension-name"), + ); + + expect(menuItemPathsForExtension).toEqual([ + ["root", "some-top-menu-item", "some-extension-name/some-clickable-item"], + // Note: anonymous index "1" is used by the non-visible menu item. + ["root", "some-top-menu-item", "some-extension-name/2-separator"], + ["root", "some-top-menu-item", "some-extension-name/some-os-action-menu-item-id"], + ["root", "some-top-menu-item", "some-extension-name/some-submenu-with-explicit-children"], + ["root", "some-top-menu-item", "some-extension-name/some-submenu-with-explicit-children", "some-extension-name/some-submenu-with-explicit-children/some-explicit-child"], + ]); + }); + + it("when the extension-based clickable menu item is clicked, does so", () => { + builder.applicationMenu.click( + "root", "some-top-menu-item", "some-extension-name/some-clickable-item", + ); + + expect(onClickMock).toHaveBeenCalled(); + }); + + describe("when the extension is disabled", () => { + beforeEach(() => { + builder.extensions.disable(testExtensionOptions); + }); + + it("when related menu items no longer exist", () => { + const menuItemPathsForExtension = builder.applicationMenu.items.filter( + (x) => + x.join(".").startsWith("root.some-top-menu-item.some-extension-name"), + ); + + expect(menuItemPathsForExtension).toEqual([]); + }); + + it("when the extension is enabled again, also related menu items exist again", () => { + builder.extensions.enable(testExtensionOptions); + + const menuItemPathsForExtension = builder.applicationMenu.items.filter( + (x) => + x.join(".").startsWith("root.some-top-menu-item.some-extension-name"), + ); + + expect(menuItemPathsForExtension).toEqual([ + ["root", "some-top-menu-item", "some-extension-name/some-clickable-item"], + ["root", "some-top-menu-item", "some-extension-name/2-separator"], + ["root", "some-top-menu-item", "some-extension-name/some-os-action-menu-item-id"], + ["root", "some-top-menu-item", "some-extension-name/some-submenu-with-explicit-children"], + ["root", "some-top-menu-item", "some-extension-name/some-submenu-with-explicit-children", "some-extension-name/some-submenu-with-explicit-children/some-explicit-child"], + ]); + }); + }); + }); + + describe("when extension with unrecognizable application menu items is enabled", () => { + + beforeEach(() => { + const testExtensionOptions: FakeExtensionOptions = { + id: "some-test-extension", + name: "some-extension-name", + + mainOptions: { + appMenus: [ + { + id: "some-recognizable-item", + parentId: "some-top-menu-item", + click: noop, + }, + + { + id: "some-unrecognizable-item", + parentId: "some-top-menu-item", + // Note: there is no way to recognize this + // click: noop, + // role: "help" + // submenu: [] + // type: "separator" + }, + ], + }, + }; + + builder.extensions.enable(testExtensionOptions); + }); + + it("only recognizable menu items from extension exist", () => { + const menuItemPathsForExtension = builder.applicationMenu.items.filter( + (x) => + x.join(".").startsWith("root.some-top-menu-item.some-extension-name"), + ); + + expect(menuItemPathsForExtension).toEqual([ + ["root", "some-top-menu-item", "some-extension-name/some-recognizable-item"], + ]); + }); + + it("logs about the unrecognizable item", () => { + expect(logErrorMock).toHaveBeenCalledWith( + '[MENU]: Tried to register menu item "some-extension-name/some-unrecognizable-item" but it is not recognizable as any of ApplicationMenuItemTypes', + ); + }); + }); +}); + +const someTopMenuItemInjectable = getInjectable({ + id: "some-top-menu-item", + + instantiate: () => ({ + id: "some-top-menu-item", + parentId: "root" as const, + kind: "top-level-menu" as const, + label: "Some existing root menu item", + orderNumber: 42, + }), + + injectionToken: applicationMenuItemInjectionToken, +}); + +const someNonExtensionBasedMenuItemInjectable = getInjectable({ + id: "some-non-extension-based-menu-item", + + instantiate: () => ({ + id: "some-non-extension-based-menu-item", + parentId: "some-top-menu-item", + kind: "clickable-menu-item" as const, + label: "Some menu item", + onClick: () => {}, + orderNumber: 42, + }), + + injectionToken: applicationMenuItemInjectionToken, +}); diff --git a/src/features/application-menu/application-menu.test.ts b/src/features/application-menu/application-menu.test.ts new file mode 100644 index 0000000000..e0128b83fb --- /dev/null +++ b/src/features/application-menu/application-menu.test.ts @@ -0,0 +1,59 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import populateApplicationMenuInjectable from "./main/populate-application-menu.injectable"; +import { advanceFakeTime, useFakeTime } from "../../common/test-utils/use-fake-time"; +import { getCompositePaths } from "../../common/utils/composite/get-composite-paths/get-composite-paths"; +import platformInjectable, { allPlatforms } from "../../common/vars/platform.injectable"; + +describe.each(allPlatforms)("application-menu, given platform is '%s'", (platform) => { + let builder: ApplicationBuilder; + let populateApplicationMenuMock: jest.Mock; + + beforeEach(async () => { + useFakeTime(); + + populateApplicationMenuMock = jest.fn(); + + builder = getApplicationBuilder(); + + builder.beforeApplicationStart((mainDi) => { + mainDi.override(platformInjectable, () => platform); + + mainDi.override( + populateApplicationMenuInjectable, + () => populateApplicationMenuMock, + ); + }); + + await builder.startHidden(); + }); + + it("when insufficient time passes, does not populate menu items yet", () => { + advanceFakeTime(99); + + expect(populateApplicationMenuMock).not.toHaveBeenCalled(); + }); + + describe("given enough time passes", () => { + let applicationMenuPaths: string[][]; + + beforeEach(() => { + advanceFakeTime(100); + applicationMenuPaths = getCompositePaths( + populateApplicationMenuMock.mock.calls[0][0], + ); + }); + + it("populates application menu with at least something", () => { + expect(applicationMenuPaths.length).toBeGreaterThan(0); + }); + + it("populates application menu", () => { + expect(applicationMenuPaths.map(x => x.join(" -> "))).toMatchSnapshot(); + }); + }); +}); diff --git a/src/features/application-menu/handling-of-orphan-application-menu-items.test.ts b/src/features/application-menu/handling-of-orphan-application-menu-items.test.ts new file mode 100644 index 0000000000..7cae6d0079 --- /dev/null +++ b/src/features/application-menu/handling-of-orphan-application-menu-items.test.ts @@ -0,0 +1,81 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import populateApplicationMenuInjectable from "./main/populate-application-menu.injectable"; +import { advanceFakeTime, useFakeTime } from "../../common/test-utils/use-fake-time"; +import { getCompositePaths } from "../../common/utils/composite/get-composite-paths/get-composite-paths"; +import { getInjectable } from "@ogre-tools/injectable"; +import applicationMenuItemInjectionToken from "./main/menu-items/application-menu-item-injection-token"; +import { runInAction } from "mobx"; +import logErrorInjectable from "../../common/log-error.injectable"; + +describe("handling-of-orphan-application-menu-items, given orphan menu item", () => { + let builder: ApplicationBuilder; + let populateApplicationMenuMock: jest.Mock; + let logErrorMock: jest.Mock; + + beforeEach(async () => { + useFakeTime(); + + populateApplicationMenuMock = jest.fn(); + logErrorMock = jest.fn(); + + builder = getApplicationBuilder(); + + builder.beforeApplicationStart((mainDi) => { + const someOrphanMenuItemInjectable = getInjectable({ + id: "some-orphan-menu-item", + instantiate: () => ({ + kind: "sub-menu" as const, + id: "some-item-id", + // Note: unknown id makes this item an orphan. + parentId: "some-unknown-parent-id", + orderNumber: 0, + label: "irrelevant", + }), + + injectionToken: applicationMenuItemInjectionToken, + }); + + runInAction(() => { + mainDi.register(someOrphanMenuItemInjectable); + }); + + mainDi.override(logErrorInjectable, () => logErrorMock); + + mainDi.override( + populateApplicationMenuInjectable, + () => populateApplicationMenuMock, + ); + }); + + await builder.startHidden(); + }); + + describe("given some time passes", () => { + let applicationMenuPaths: string[][]; + + beforeEach(() => { + advanceFakeTime(100); + + applicationMenuPaths = getCompositePaths( + populateApplicationMenuMock.mock.calls[0][0], + ); + }); + + it("keeps showing the other application menu items without throwing", () => { + expect(applicationMenuPaths.length).toBeGreaterThan(0); + }); + + it("does not show orphan application menu item", () => { + expect(applicationMenuPaths.find(x => x.join(".").endsWith("some-item-id"))); + }); + + it("logs about bad menu item", () => { + expect(logErrorMock).toHaveBeenCalledWith('[MENU]: cannot render menu item for missing parentIds: "some-unknown-parent-id"'); + }); + }); +}); diff --git a/src/features/application-menu/main/application-menu-item-composite.injectable.ts b/src/features/application-menu/main/application-menu-item-composite.injectable.ts new file mode 100644 index 0000000000..b9012fb67b --- /dev/null +++ b/src/features/application-menu/main/application-menu-item-composite.injectable.ts @@ -0,0 +1,64 @@ +/** + * 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 applicationMenuItemsInjectable from "./application-menu-items.injectable"; +import type { Composite } from "../../../common/utils/composite/get-composite/get-composite"; +import { getCompositeFor } from "../../../common/utils/composite/get-composite/get-composite"; +import { computed } from "mobx"; +import { pipeline } from "@ogre-tools/fp"; +import type { ApplicationMenuItemTypes } from "./menu-items/application-menu-item-injection-token"; +import type { RootComposite } from "../../../common/utils/composite/interfaces"; +import type { Discriminable } from "../../../common/utils/composable-responsibilities/discriminable/discriminable"; +import { orderByOrderNumber } from "../../../common/utils/composable-responsibilities/orderable/orderable"; +import logErrorInjectable from "../../../common/log-error.injectable"; +import { isShown } from "../../../common/utils/composable-responsibilities/showable/showable"; + +export type MenuItemRoot = Discriminable<"root"> & RootComposite<"root">; + +const applicationMenuItemCompositeInjectable = getInjectable({ + id: "application-menu-item-composite", + + instantiate: (di) => { + const menuItems = di.inject(applicationMenuItemsInjectable); + const logError = di.inject(logErrorInjectable); + + return computed((): Composite => { + const items = menuItems.get(); + + return pipeline( + [ + { + parentId: undefined, + id: "root", + kind: "root", + } as const, + + ...items, + ], + + getCompositeFor({ + getId: (x) => x.id, + getParentId: (x) => x.parentId, + transformChildren: (children) => + pipeline( + children, + orderByOrderNumber, + (children) => children.filter(isShown), + ), + + handleMissingParentIds: ({ missingParentIds }) => { + logError( + `[MENU]: cannot render menu item for missing parentIds: "${missingParentIds.join( + '", "', + )}"`, + ); + }, + }), + ); + }); + }, +}); + +export default applicationMenuItemCompositeInjectable; diff --git a/src/features/application-menu/main/application-menu-item-registrator.injectable.ts b/src/features/application-menu/main/application-menu-item-registrator.injectable.ts new file mode 100644 index 0000000000..8c3d635db0 --- /dev/null +++ b/src/features/application-menu/main/application-menu-item-registrator.injectable.ts @@ -0,0 +1,158 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Injectable } from "@ogre-tools/injectable"; +import { getInjectable } from "@ogre-tools/injectable"; +import { extensionRegistratorInjectionToken } from "../../../extensions/extension-loader/extension-registrator-injection-token"; +import type { LensExtension } from "../../../extensions/lens-extension"; +import type { LensMainExtension } from "../../../extensions/lens-main-extension"; +import type { + ApplicationMenuItemTypes, + ClickableMenuItem, + OsActionMenuItem, + Separator, +} from "./menu-items/application-menu-item-injection-token"; +import applicationMenuItemInjectionToken from "./menu-items/application-menu-item-injection-token"; +import type { MenuRegistration } from "./menu-registration"; +import logErrorInjectable from "../../../common/log-error.injectable"; + +const applicationMenuItemRegistratorInjectable = getInjectable({ + id: "application-menu-item-registrator", + + instantiate: (di) => { + const logError = di.inject(logErrorInjectable); + const toRecursedInjectables = toRecursedInjectablesFor(logError); + + return (ext: LensExtension) => { + const extension = ext as LensMainExtension; + + return extension.appMenus.flatMap( + toRecursedInjectables([extension.sanitizedExtensionId]), + ); + }; + }, + + injectionToken: extensionRegistratorInjectionToken, +}); + +export default applicationMenuItemRegistratorInjectable; + +const toRecursedInjectablesFor = (logError: (errorMessage: string) => void) => { + const toRecursedInjectables = (previousIdPath: string[]) => + ( + registration: MenuRegistration, + index: number, + // Todo: new version of injectable would require less type parameters with defaults. + ): Injectable< + ApplicationMenuItemTypes, + ApplicationMenuItemTypes, + void + >[] => { + const previousIdPathString = previousIdPath.join("/"); + const registrationId = registration.id || index.toString(); + const currentIdPath = [...previousIdPath, registrationId]; + const currentIdPathString = currentIdPath.join("/"); + const parentId = registration.parentId || previousIdPathString; + + const menuItem = getApplicationMenuItem({ + registration, + parentId, + currentIdPathString, + index, + }); + + if (!menuItem) { + logError(`[MENU]: Tried to register menu item "${currentIdPathString}" but it is not recognizable as any of ApplicationMenuItemTypes`); + + return []; + } + + return [ + getInjectable({ + id: `${currentIdPathString}/application-menu-item`, + + instantiate: () => menuItem, + + injectionToken: applicationMenuItemInjectionToken, + }), + + ...((registration.submenu as MenuRegistration[]) + ? (registration.submenu as MenuRegistration[]).flatMap( + toRecursedInjectables(currentIdPath), + ) + : []), + ]; + }; + + return toRecursedInjectables; +}; + +const getApplicationMenuItem = ({ + registration, + index, + currentIdPathString, + parentId, +}: { + registration: MenuRegistration; + index: number; + currentIdPathString: string; + parentId: string; +}): ApplicationMenuItemTypes | undefined => { + const orderNumber = 1000 + index * 10; + + if (registration.type === "separator") { + return { + kind: "separator" as const, + id: `${currentIdPathString}-separator`, + parentId, + orderNumber, + } as Separator; + } + + if (registration.submenu) { + return { + kind: "sub-menu" as const, + id: currentIdPathString, + parentId, + isShown: registration.visible ?? true, + orderNumber, + label: registration.label || "", + }; + } + + if (registration.click) { + return { + kind: "clickable-menu-item" as const, + id: currentIdPathString, + parentId, + // Todo: hide electron events from this abstraction. + onClick: registration.click, + label: registration.label, + isShown: registration.visible ?? true, + orderNumber, + + ...(registration.accelerator + ? { keyboardShortcut: registration.accelerator as string } + : {}), + } as ClickableMenuItem; + } + + if (registration.role) { + return { + kind: "os-action-menu-item" as const, + id: currentIdPathString, + parentId, + label: registration.label, + isShown: registration.visible ?? true, + orderNumber, + actionName: registration.role, + + ...(registration.accelerator + ? { keyboardShortcut: registration.accelerator as string } + : {}), + } as OsActionMenuItem; + } + + return undefined; +}; diff --git a/src/features/application-menu/main/application-menu-items.injectable.ts b/src/features/application-menu/main/application-menu-items.injectable.ts new file mode 100644 index 0000000000..7cb8cd3ef4 --- /dev/null +++ b/src/features/application-menu/main/application-menu-items.injectable.ts @@ -0,0 +1,28 @@ +/** + * 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 type { MenuItemConstructorOptions } from "electron"; +import { computed } from "mobx"; +import applicationMenuItemInjectionToken from "./menu-items/application-menu-item-injection-token"; +import { computedInjectManyInjectable } from "@ogre-tools/injectable-extension-for-mobx"; + +export interface MenuItemOpts extends MenuItemConstructorOptions { + submenu?: MenuItemConstructorOptions[]; +} + +const applicationMenuItemsInjectable = getInjectable({ + id: "application-menu-items", + + instantiate: (di) => { + const computedInjectMany = di.inject(computedInjectManyInjectable); + + return computed(() => + computedInjectMany(applicationMenuItemInjectionToken).get(), + ); + }, +}); + + +export default applicationMenuItemsInjectable; diff --git a/src/features/application-menu/main/application-menu-reactivity.injectable.ts b/src/features/application-menu/main/application-menu-reactivity.injectable.ts new file mode 100644 index 0000000000..80bfd108fd --- /dev/null +++ b/src/features/application-menu/main/application-menu-reactivity.injectable.ts @@ -0,0 +1,27 @@ +/** + * 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 { autorun } from "mobx"; +import { getStartableStoppable } from "../../../common/utils/get-startable-stoppable"; +import populateApplicationMenuInjectable from "./populate-application-menu.injectable"; +import applicationMenuItemCompositeInjectable from "./application-menu-item-composite.injectable"; + +const applicationMenuReactivityInjectable = getInjectable({ + id: "application-menu-reactivity", + + instantiate: (di) => { + const applicationMenuItemComposite = di.inject(applicationMenuItemCompositeInjectable); + const populateApplicationMenu = di.inject(populateApplicationMenuInjectable); + + return getStartableStoppable( + "application-menu-reactivity", + () => autorun(() => populateApplicationMenu(applicationMenuItemComposite.get()), { + delay: 100, + }), + ); + }, +}); + +export default applicationMenuReactivityInjectable; diff --git a/src/features/application-menu/main/menu-items/application-menu-item-injection-token.ts b/src/features/application-menu/main/menu-items/application-menu-item-injection-token.ts new file mode 100644 index 0000000000..b90e5e1612 --- /dev/null +++ b/src/features/application-menu/main/menu-items/application-menu-item-injection-token.ts @@ -0,0 +1,91 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { BrowserWindow, KeyboardEvent, MenuItemConstructorOptions, MenuItem as ElectronMenuItem } from "electron"; +import type { SetOptional } from "type-fest"; +import type { ChildOfParentComposite, ParentOfChildComposite } from "../../../../common/utils/composite/interfaces"; +import type { MaybeShowable } from "../../../../common/utils/composable-responsibilities/showable/showable"; +import type { Discriminable } from "../../../../common/utils/composable-responsibilities/discriminable/discriminable"; +import type { Orderable } from "../../../../common/utils/composable-responsibilities/orderable/orderable"; + +export interface MayHaveKeyboardShortcut { + keyboardShortcut?: string; +} + +export interface ElectronClickable { + // TODO: This leaky abstraction is exposed in Extension API, therefore cannot be updated + onClick: (menuItem: ElectronMenuItem, browserWindow: (BrowserWindow) | (undefined), event: KeyboardEvent) => void; +} + +export interface Labeled { + label: string; +} + +export interface MaybeLabeled extends SetOptional {} + +type ApplicationMenuItemType = + // Note: "kind" is being used for Discriminated unions of TypeScript to achieve type narrowing. + // See: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions + & Discriminable + & ParentOfChildComposite + & ChildOfParentComposite + & MaybeShowable + & Orderable; + +export type TopLevelMenu = + & ApplicationMenuItemType<"top-level-menu"> + & { parentId: "root" } + & Labeled + & MayHaveElectronRole; + +interface MayHaveElectronRole { + role?: ElectronRoles; +} + +type ElectronRoles = Exclude; + +export type SubMenu = + & ApplicationMenuItemType<"sub-menu"> + & Labeled + & ChildOfParentComposite; + +export type ClickableMenuItem = + & ApplicationMenuItemType<"clickable-menu-item"> + & MenuItem + & Labeled + & ElectronClickable; + +export type OsActionMenuItem = + & ApplicationMenuItemType<"os-action-menu-item"> + & MenuItem + & MaybeLabeled + & TriggersElectronAction; + +type MenuItem = + & ChildOfParentComposite + & MayHaveKeyboardShortcut; + +interface TriggersElectronAction { + actionName: ElectronRoles; +} + +// Todo: SeparatorMenuItem +export type Separator = + & ApplicationMenuItemType<"separator"> + & ChildOfParentComposite; + +export type ApplicationMenuItemTypes = + | TopLevelMenu + | SubMenu + | OsActionMenuItem + | ClickableMenuItem + | Separator +; + +const applicationMenuItemInjectionToken = getInjectionToken({ + id: "application-menu-item-injection-token", +}); + +export default applicationMenuItemInjectionToken; diff --git a/src/features/application-menu/main/menu-items/edit/edit-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/edit/edit-menu-item.injectable.ts new file mode 100644 index 0000000000..f62a4221ac --- /dev/null +++ b/src/features/application-menu/main/menu-items/edit/edit-menu-item.injectable.ts @@ -0,0 +1,22 @@ +/** + * 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 applicationMenuItemInjectionToken from "../application-menu-item-injection-token"; + +const editMenuItemInjectable = getInjectable({ + id: "edit-application-menu-item", + + instantiate: () => ({ + kind: "top-level-menu" as const, + id: "edit", + parentId: "root" as const, + orderNumber: 30, + label: "Edit", + }), + + injectionToken: applicationMenuItemInjectionToken, +}); + +export default editMenuItemInjectable; diff --git a/src/features/application-menu/main/menu-items/edit/operation-system-actions/operation-system-actions.injectable.ts b/src/features/application-menu/main/menu-items/edit/operation-system-actions/operation-system-actions.injectable.ts new file mode 100644 index 0000000000..aea229cc68 --- /dev/null +++ b/src/features/application-menu/main/menu-items/edit/operation-system-actions/operation-system-actions.injectable.ts @@ -0,0 +1,69 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getApplicationMenuOperationSystemActionInjectable } from "../../get-application-menu-operation-system-action-injectable"; +import { getApplicationMenuSeparatorInjectable } from "../../get-application-menu-separator-injectable"; + +export const actionForUndo = getApplicationMenuOperationSystemActionInjectable({ + id: "undo", + parentId: "edit", + orderNumber: 10, + actionName: "undo", +}); + +export const actionForRedo = getApplicationMenuOperationSystemActionInjectable({ + id: "redo", + parentId: "edit", + orderNumber: 20, + actionName: "redo", +}); + +export const separator1 = getApplicationMenuSeparatorInjectable({ + id: "separator-1-in-edit", + parentId: "edit", + orderNumber: 30, +}); + +export const actionForCut = getApplicationMenuOperationSystemActionInjectable({ + id: "cut", + parentId: "edit", + orderNumber: 40, + actionName: "cut", +}); + +export const actionForCopy = getApplicationMenuOperationSystemActionInjectable({ + id: "copy", + parentId: "edit", + orderNumber: 50, + actionName: "copy", +}); + +export const actionForPaste = getApplicationMenuOperationSystemActionInjectable({ + id: "paste", + parentId: "edit", + orderNumber: 60, + actionName: "paste", +}); + +export const actionForDelete = getApplicationMenuOperationSystemActionInjectable({ + id: "delete", + parentId: "edit", + orderNumber: 70, + actionName: "delete", +}); + +export const separator2 = getApplicationMenuSeparatorInjectable({ + id: "separator-2-in-edit", + parentId: "edit", + orderNumber: 80, +}); + +export const actionForSelectAll = getApplicationMenuOperationSystemActionInjectable({ + id: "selectAll", + parentId: "edit", + orderNumber: 90, + actionName: "selectAll", +}); + diff --git a/src/features/application-menu/main/menu-items/file/add-cluster/add-cluster-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/file/add-cluster/add-cluster-menu-item.injectable.ts new file mode 100644 index 0000000000..78c7201ca6 --- /dev/null +++ b/src/features/application-menu/main/menu-items/file/add-cluster/add-cluster-menu-item.injectable.ts @@ -0,0 +1,32 @@ +/** + * 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 applicationMenuItemInjectionToken from "../../application-menu-item-injection-token"; +import navigateToAddClusterInjectable from "../../../../../../common/front-end-routing/routes/add-cluster/navigate-to-add-cluster.injectable"; + +const addClusterMenuItemInjectable = getInjectable({ + id: "add-cluster-application-menu-item", + + instantiate: (di) => { + const navigateToAddCluster = di.inject(navigateToAddClusterInjectable); + + return { + kind: "clickable-menu-item" as const, + parentId: "file", + id: "add-cluster", + orderNumber: 10, + label: "Add Cluster", + keyboardShortcut: "CmdOrCtrl+Shift+A", + + onClick: () => { + navigateToAddCluster(); + }, + }; + }, + + injectionToken: applicationMenuItemInjectionToken, +}); + +export default addClusterMenuItemInjectable; diff --git a/src/features/application-menu/main/menu-items/file/close-window/close-window-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/file/close-window/close-window-menu-item.injectable.ts new file mode 100644 index 0000000000..745adb55df --- /dev/null +++ b/src/features/application-menu/main/menu-items/file/close-window/close-window-menu-item.injectable.ts @@ -0,0 +1,30 @@ +/** + * 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 applicationMenuItemInjectionToken from "../../application-menu-item-injection-token"; +import isMacInjectable from "../../../../../../common/vars/is-mac.injectable"; + +const closeWindowMenuItemInjectable = getInjectable({ + id: "close-window-application-menu-item", + + instantiate: (di) => { + const isMac = di.inject(isMacInjectable); + + return { + id: "close-window", + kind: "os-action-menu-item" as const, + parentId: "file", + orderNumber: 60, + actionName: "close" as const, + label: "Close Window", + keyboardShortcut: "Shift+Cmd+W", + isShown: isMac, + }; + }, + + injectionToken: applicationMenuItemInjectionToken, +}); + +export default closeWindowMenuItemInjectable; diff --git a/src/features/application-menu/main/menu-items/file/file-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/file/file-menu-item.injectable.ts new file mode 100644 index 0000000000..dce2bb5cef --- /dev/null +++ b/src/features/application-menu/main/menu-items/file/file-menu-item.injectable.ts @@ -0,0 +1,22 @@ +/** + * 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 applicationMenuItemInjectionToken from "../application-menu-item-injection-token"; + +const fileMenuItemInjectable = getInjectable({ + id: "file-application-menu-item", + + instantiate: () => ({ + kind: "top-level-menu" as const, + id: "file", + parentId: "root" as const, + orderNumber: 20, + label: "File", + }), + + injectionToken: applicationMenuItemInjectionToken, +}); + +export default fileMenuItemInjectable; diff --git a/src/features/application-menu/main/menu-items/file/separators/separators.injectable.ts b/src/features/application-menu/main/menu-items/file/separators/separators.injectable.ts new file mode 100644 index 0000000000..a0f2cbe5d8 --- /dev/null +++ b/src/features/application-menu/main/menu-items/file/separators/separators.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { + getApplicationMenuSeparatorInjectable, +} from "../../get-application-menu-separator-injectable"; + +export const separator1 = getApplicationMenuSeparatorInjectable({ + id: "separator-1-for-file", + parentId: "file", + orderNumber: 20, + isShownOnlyOnMac: true, +}); diff --git a/src/features/application-menu/main/menu-items/get-application-menu-operation-system-action-injectable.ts b/src/features/application-menu/main/menu-items/get-application-menu-operation-system-action-injectable.ts new file mode 100644 index 0000000000..ebe6a6865e --- /dev/null +++ b/src/features/application-menu/main/menu-items/get-application-menu-operation-system-action-injectable.ts @@ -0,0 +1,25 @@ +/** + * 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 type { OsActionMenuItem } from "./application-menu-item-injection-token"; +import applicationMenuItemInjectionToken from "./application-menu-item-injection-token"; + +const getApplicationMenuOperationSystemActionInjectable = ({ + id, + ...rest +}: Omit) => + getInjectable({ + id: `application-menu-operation-system-action/${id}`, + + instantiate: () => ({ + ...rest, + id, + kind: "os-action-menu-item" as const, + }), + + injectionToken: applicationMenuItemInjectionToken, + }); + +export { getApplicationMenuOperationSystemActionInjectable }; diff --git a/src/features/application-menu/main/menu-items/get-application-menu-separator-injectable.ts b/src/features/application-menu/main/menu-items/get-application-menu-separator-injectable.ts new file mode 100644 index 0000000000..d9eb1677cc --- /dev/null +++ b/src/features/application-menu/main/menu-items/get-application-menu-separator-injectable.ts @@ -0,0 +1,36 @@ +/** + * 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 type { Separator } from "./application-menu-item-injection-token"; +import applicationMenuItemInjectionToken from "./application-menu-item-injection-token"; +import isMacInjectable from "../../../../common/vars/is-mac.injectable"; + +const getApplicationMenuSeparatorInjectable = ({ + id, + isShownOnlyOnMac = false, + ...rest +}: { isShownOnlyOnMac?: boolean } & Omit< + Separator, + "kind" | "isShown" +>) => + getInjectable({ + id: `application-menu-separator/${id}`, + + instantiate: (di) => { + const isMac = di.inject(isMacInjectable); + const isShown = isShownOnlyOnMac ? isMac : true; + + return { + ...rest, + id, + kind: "separator" as const, + isShown, + }; + }, + + injectionToken: applicationMenuItemInjectionToken, + }); + +export { getApplicationMenuSeparatorInjectable }; diff --git a/src/features/application-menu/main/menu-items/help/help-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/help/help-menu-item.injectable.ts new file mode 100644 index 0000000000..5b83115362 --- /dev/null +++ b/src/features/application-menu/main/menu-items/help/help-menu-item.injectable.ts @@ -0,0 +1,23 @@ +/** + * 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 applicationMenuItemInjectionToken from "../application-menu-item-injection-token"; + +const helpMenuItemInjectable = getInjectable({ + id: "help-application-menu-item", + + instantiate: () => ({ + kind: "top-level-menu" as const, + id: "help", + parentId: "root" as const, + orderNumber: 50, + label: "Help", + role: "help" as const, + }), + + injectionToken: applicationMenuItemInjectionToken, +}); + +export default helpMenuItemInjectable; diff --git a/src/features/application-menu/main/menu-items/help/navigate-to-welcome/navigate-to-extensions-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/help/navigate-to-welcome/navigate-to-extensions-menu-item.injectable.ts new file mode 100644 index 0000000000..10100ecb4c --- /dev/null +++ b/src/features/application-menu/main/menu-items/help/navigate-to-welcome/navigate-to-extensions-menu-item.injectable.ts @@ -0,0 +1,31 @@ +/** + * 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 applicationMenuItemInjectionToken from "../../application-menu-item-injection-token"; +import navigateToWelcomeInjectable from "../../../../../../common/front-end-routing/routes/welcome/navigate-to-welcome.injectable"; + +const navigateToWelcomeMenuItem = getInjectable({ + id: "navigate-to-welcome-menu-item", + + instantiate: (di) => { + const navigateToWelcome = di.inject(navigateToWelcomeInjectable); + + return { + kind: "clickable-menu-item" as const, + parentId: "help", + id: "navigate-to-welcome", + orderNumber: 10, + label: "Welcome", + + onClick: () => { + navigateToWelcome(); + }, + }; + }, + + injectionToken: applicationMenuItemInjectionToken, +}); + +export default navigateToWelcomeMenuItem; diff --git a/src/features/application-menu/main/menu-items/help/open-documentation/open-documentation-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/help/open-documentation/open-documentation-menu-item.injectable.ts new file mode 100644 index 0000000000..ca63120810 --- /dev/null +++ b/src/features/application-menu/main/menu-items/help/open-documentation/open-documentation-menu-item.injectable.ts @@ -0,0 +1,37 @@ +/** + * 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 applicationMenuItemInjectionToken from "../../application-menu-item-injection-token"; +import { docsUrl } from "../../../../../../common/vars"; +import openLinkInBrowserInjectable from "../../../../../../common/utils/open-link-in-browser.injectable"; +import loggerInjectable from "../../../../../../common/logger.injectable"; + +const openDocumentationMenuItemInjectable = getInjectable({ + id: "open-documentation-menu-item", + + instantiate: (di) => { + const openLinkInBrowser = di.inject(openLinkInBrowserInjectable); + const logger = di.inject(loggerInjectable); + + return { + kind: "clickable-menu-item" as const, + parentId: "help", + id: "open-documentation", + orderNumber: 20, + label: "Documentation", + + // TODO: Convert to async/await + onClick: () => { + openLinkInBrowser(docsUrl).catch((error) => { + logger.error("[MENU]: failed to open browser", { error }); + }); + }, + }; + }, + + injectionToken: applicationMenuItemInjectionToken, +}); + +export default openDocumentationMenuItemInjectable; diff --git a/src/features/application-menu/main/menu-items/help/open-support/open-support-item.injectable.ts b/src/features/application-menu/main/menu-items/help/open-support/open-support-item.injectable.ts new file mode 100644 index 0000000000..7a1db63078 --- /dev/null +++ b/src/features/application-menu/main/menu-items/help/open-support/open-support-item.injectable.ts @@ -0,0 +1,37 @@ +/** + * 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 applicationMenuItemInjectionToken from "../../application-menu-item-injection-token"; +import { supportUrl } from "../../../../../../common/vars"; +import openLinkInBrowserInjectable from "../../../../../../common/utils/open-link-in-browser.injectable"; +import loggerInjectable from "../../../../../../common/logger.injectable"; + +const openSupportItemInjectable = getInjectable({ + id: "open-support-menu-item", + + instantiate: (di) => { + const openLinkInBrowser = di.inject(openLinkInBrowserInjectable); + const logger = di.inject(loggerInjectable); + + return { + kind: "clickable-menu-item" as const, + parentId: "help", + id: "open-support", + orderNumber: 30, + label: "Support", + + // TODO: Convert to async/await + onClick: () => { + openLinkInBrowser(supportUrl).catch((error) => { + logger.error("[MENU]: failed to open browser", { error }); + }); + }, + }; + }, + + injectionToken: applicationMenuItemInjectionToken, +}); + +export default openSupportItemInjectable; diff --git a/src/features/application-menu/main/menu-items/special-menu-for-mac-application/navigate-to-extensions/navigate-to-extensions-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/special-menu-for-mac-application/navigate-to-extensions/navigate-to-extensions-menu-item.injectable.ts new file mode 100644 index 0000000000..40aa366280 --- /dev/null +++ b/src/features/application-menu/main/menu-items/special-menu-for-mac-application/navigate-to-extensions/navigate-to-extensions-menu-item.injectable.ts @@ -0,0 +1,34 @@ +/** + * 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 applicationMenuItemInjectionToken from "../../application-menu-item-injection-token"; +import navigateToExtensionsInjectable from "../../../../../../common/front-end-routing/routes/extensions/navigate-to-extensions.injectable"; +import isMacInjectable from "../../../../../../common/vars/is-mac.injectable"; + +const navigateToExtensionsMenuItem = getInjectable({ + id: "navigate-to-extensions-menu-item", + + instantiate: (di) => { + const navigateToExtensions = di.inject(navigateToExtensionsInjectable); + const isMac = di.inject(isMacInjectable); + + return { + kind: "clickable-menu-item" as const, + parentId: isMac ? "mac" : "file", + id: "navigate-to-extensions", + orderNumber: isMac ? 50 : 40, + label: "Extensions", + keyboardShortcut: isMac ? "CmdOrCtrl+Shift+E" : "Ctrl+Shift+E", + + onClick: () => { + navigateToExtensions(); + }, + }; + }, + + injectionToken: applicationMenuItemInjectionToken, +}); + +export default navigateToExtensionsMenuItem; diff --git a/src/features/application-menu/main/menu-items/special-menu-for-mac-application/operation-system-actions/operation-system-actions.injectable.ts b/src/features/application-menu/main/menu-items/special-menu-for-mac-application/operation-system-actions/operation-system-actions.injectable.ts new file mode 100644 index 0000000000..1c78d81031 --- /dev/null +++ b/src/features/application-menu/main/menu-items/special-menu-for-mac-application/operation-system-actions/operation-system-actions.injectable.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { + getApplicationMenuOperationSystemActionInjectable, +} from "../../get-application-menu-operation-system-action-injectable"; + +export const actionForServices = getApplicationMenuOperationSystemActionInjectable({ + id: "services", + parentId: "mac", + orderNumber: 80, + actionName: "services", +}); + +export const actionForHide = getApplicationMenuOperationSystemActionInjectable({ + id: "hide", + parentId: "mac", + orderNumber: 100, + actionName: "hide", +}); + +export const actionForHideOthers = getApplicationMenuOperationSystemActionInjectable({ + id: "hide-others", + parentId: "mac", + orderNumber: 110, + actionName: "hideOthers", +}); + +export const actionForUnhide = getApplicationMenuOperationSystemActionInjectable({ + id: "unhide", + parentId: "mac", + orderNumber: 120, + actionName: "unhide", +}); diff --git a/src/features/application-menu/main/menu-items/special-menu-for-mac-application/primary-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/special-menu-for-mac-application/primary-menu-item.injectable.ts new file mode 100644 index 0000000000..b85df1d511 --- /dev/null +++ b/src/features/application-menu/main/menu-items/special-menu-for-mac-application/primary-menu-item.injectable.ts @@ -0,0 +1,30 @@ +/** + * 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 applicationMenuItemInjectionToken from "../application-menu-item-injection-token"; +import appNameInjectable from "../../../../../common/vars/app-name.injectable"; +import isMacInjectable from "../../../../../common/vars/is-mac.injectable"; + +const primaryMenuItemInjectable = getInjectable({ + id: "primary-application-menu-item", + + instantiate: (di) => { + const appName = di.inject(appNameInjectable); + const isMac = di.inject(isMacInjectable); + + return { + kind: "top-level-menu" as const, + parentId: "root" as const, + id: "mac", + orderNumber: 10, + label: appName, + isShown: isMac, + }; + }, + + injectionToken: applicationMenuItemInjectionToken, +}); + +export default primaryMenuItemInjectable; diff --git a/src/features/application-menu/main/menu-items/special-menu-for-mac-application/quit-application/quit-application-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/special-menu-for-mac-application/quit-application/quit-application-menu-item.injectable.ts new file mode 100644 index 0000000000..71f7cde125 --- /dev/null +++ b/src/features/application-menu/main/menu-items/special-menu-for-mac-application/quit-application/quit-application-menu-item.injectable.ts @@ -0,0 +1,35 @@ +/** + * 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 applicationMenuItemInjectionToken from "../../application-menu-item-injection-token"; +import stopServicesAndExitAppInjectable from "../../../../../../main/stop-services-and-exit-app.injectable"; +import isMacInjectable from "../../../../../../common/vars/is-mac.injectable"; + +const quitApplicationMenuItemInjectable = getInjectable({ + id: "quit-application-menu-item", + + instantiate: (di) => { + const stopServicesAndExitApp = di.inject(stopServicesAndExitAppInjectable); + const isMac = di.inject(isMacInjectable); + + return { + kind: "clickable-menu-item" as const, + id: "quit", + label: "Quit", + + parentId: isMac ? "mac" : "file", + orderNumber: isMac ? 140 : 70, + keyboardShortcut: isMac ? "Cmd+Q" : "Alt+F4", + + onClick: () => { + stopServicesAndExitApp(); + }, + }; + }, + + injectionToken: applicationMenuItemInjectionToken, +}); + +export default quitApplicationMenuItemInjectable; diff --git a/src/features/application-menu/main/menu-items/special-menu-for-mac-application/separators/separators.injectable.ts b/src/features/application-menu/main/menu-items/special-menu-for-mac-application/separators/separators.injectable.ts new file mode 100644 index 0000000000..e878df9210 --- /dev/null +++ b/src/features/application-menu/main/menu-items/special-menu-for-mac-application/separators/separators.injectable.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { + getApplicationMenuSeparatorInjectable, +} from "../../get-application-menu-separator-injectable"; + +export const separator1 = getApplicationMenuSeparatorInjectable({ + id: "separator-1", + parentId: "mac", + orderNumber: 30, +}); + +export const separator2 = getApplicationMenuSeparatorInjectable({ + id: "separator-2", + parentId: "mac", + orderNumber: 70, +}); + +export const separator3 = getApplicationMenuSeparatorInjectable({ + id: "separator-3", + parentId: "mac", + orderNumber: 90, +}); + +export const separator4 = getApplicationMenuSeparatorInjectable({ + id: "separator-4", + parentId: "mac", + orderNumber: 130, +}); diff --git a/src/features/application-menu/main/menu-items/special-menu-for-mac-application/show-about-application/about-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/special-menu-for-mac-application/show-about-application/about-menu-item.injectable.ts new file mode 100644 index 0000000000..6e269a9e3a --- /dev/null +++ b/src/features/application-menu/main/menu-items/special-menu-for-mac-application/show-about-application/about-menu-item.injectable.ts @@ -0,0 +1,35 @@ +/** + * 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 productNameInjectable from "../../../../../../common/vars/product-name.injectable"; +import showAboutInjectable from "./show-about.injectable"; +import applicationMenuItemInjectionToken from "../../application-menu-item-injection-token"; +import isMacInjectable from "../../../../../../common/vars/is-mac.injectable"; + +const aboutMenuItemInjectable = getInjectable({ + id: "about-menu-item", + + instantiate: (di) => { + const productName = di.inject(productNameInjectable); + const showAbout = di.inject(showAboutInjectable); + const isMac = di.inject(isMacInjectable); + + return { + kind: "clickable-menu-item" as const, + id: "about", + parentId: isMac ? "mac" : "help", + orderNumber: isMac ? 10 : 40, + label: `About ${productName}`, + + onClick() { + showAbout(); + }, + }; + }, + + injectionToken: applicationMenuItemInjectionToken, +}); + +export default aboutMenuItemInjectable; diff --git a/src/features/application-menu/main/menu-items/special-menu-for-mac-application/show-about-application/show-about.injectable.ts b/src/features/application-menu/main/menu-items/special-menu-for-mac-application/show-about-application/show-about.injectable.ts new file mode 100644 index 0000000000..a91e2af337 --- /dev/null +++ b/src/features/application-menu/main/menu-items/special-menu-for-mac-application/show-about-application/show-about.injectable.ts @@ -0,0 +1,45 @@ +/** + * 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 showMessagePopupInjectable from "../../../../../../main/electron-app/features/show-message-popup.injectable"; +import isWindowsInjectable from "../../../../../../common/vars/is-windows.injectable"; +import appNameInjectable from "../../../../../../common/vars/app-name.injectable"; +import productNameInjectable from "../../../../../../common/vars/product-name.injectable"; +import buildVersionInjectable from "../../../../../../main/vars/build-version/build-version.injectable"; +import extensionApiVersionInjectable from "../../../../../../common/vars/extension-api-version.injectable"; +import applicationCopyrightInjectable from "../../../../../../common/vars/application-copyright.injectable"; + +const showAboutInjectable = getInjectable({ + id: "show-about", + + instantiate: (di) => { + const buildVersion = di.inject(buildVersionInjectable); + const extensionApiVersion = di.inject(extensionApiVersionInjectable); + const showMessagePopup = di.inject(showMessagePopupInjectable); + const isWindows = di.inject(isWindowsInjectable); + const appName = di.inject(appNameInjectable); + const productName = di.inject(productNameInjectable); + const applicationCopyright = di.inject(applicationCopyrightInjectable); + + return () => { + const appInfo = [ + `${appName}: ${buildVersion.get()}`, + `Extension API: ${extensionApiVersion}`, + `Electron: ${process.versions.electron}`, + `Chrome: ${process.versions.chrome}`, + `Node: ${process.versions.node}`, + applicationCopyright, + ]; + + showMessagePopup( + `${isWindows ? " ".repeat(2) : ""}${appName}`, + productName, + appInfo.join("\r\n"), + ); + }; + }, +}); + +export default showAboutInjectable; diff --git a/src/features/application-menu/main/menu-items/view/go-back/go-back-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/view/go-back/go-back-menu-item.injectable.ts new file mode 100644 index 0000000000..42860bdc5c --- /dev/null +++ b/src/features/application-menu/main/menu-items/view/go-back/go-back-menu-item.injectable.ts @@ -0,0 +1,31 @@ +/** + * 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 applicationMenuItemInjectionToken from "../../application-menu-item-injection-token"; +import { webContents } from "electron"; + +const goBackMenuItemInjectable = getInjectable({ + id: "go-back-menu-item", + + instantiate: () => ({ + kind: "clickable-menu-item" as const, + parentId: "view", + id: "go-back", + orderNumber: 40, + label: "Back", + keyboardShortcut: "CmdOrCtrl+[", + + onClick: () => { + webContents + .getAllWebContents() + .filter((wc) => wc.getType() === "window") + .forEach((wc) => wc.goBack()); + }, + }), + + injectionToken: applicationMenuItemInjectionToken, +}); + +export default goBackMenuItemInjectable; diff --git a/src/features/application-menu/main/menu-items/view/go-forward/go-forward-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/view/go-forward/go-forward-menu-item.injectable.ts new file mode 100644 index 0000000000..e37b8d1cf7 --- /dev/null +++ b/src/features/application-menu/main/menu-items/view/go-forward/go-forward-menu-item.injectable.ts @@ -0,0 +1,31 @@ +/** + * 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 applicationMenuItemInjectionToken from "../../application-menu-item-injection-token"; +import { webContents } from "electron"; + +const goForwardMenuItemInjectable = getInjectable({ + id: "go-forward-menu-item", + + instantiate: () => ({ + kind: "clickable-menu-item" as const, + parentId: "view", + id: "go-forward", + orderNumber: 50, + label: "Forward", + keyboardShortcut: "CmdOrCtrl+]", + + onClick: () => { + webContents + .getAllWebContents() + .filter((wc) => wc.getType() === "window") + .forEach((wc) => wc.goForward()); + }, + }), + + injectionToken: applicationMenuItemInjectionToken, +}); + +export default goForwardMenuItemInjectable; diff --git a/src/features/application-menu/main/menu-items/view/navigate-to-catalog/navigate-to-catalog-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/view/navigate-to-catalog/navigate-to-catalog-menu-item.injectable.ts new file mode 100644 index 0000000000..eb414fe1ec --- /dev/null +++ b/src/features/application-menu/main/menu-items/view/navigate-to-catalog/navigate-to-catalog-menu-item.injectable.ts @@ -0,0 +1,32 @@ +/** + * 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 applicationMenuItemInjectionToken from "../../application-menu-item-injection-token"; +import navigateToCatalogInjectable from "../../../../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable"; + +const navigateToCatalogMenuItemInjectable = getInjectable({ + id: "navigate-to-catalog-menu-item", + + instantiate: (di) => { + const navigateToCatalog = di.inject(navigateToCatalogInjectable); + + return { + kind: "clickable-menu-item" as const, + parentId: "view", + id: "navigate-to-catalog", + orderNumber: 10, + label: "Catalog", + keyboardShortcut: "Shift+CmdOrCtrl+C", + + onClick: () => { + navigateToCatalog(); + }, + }; + }, + + injectionToken: applicationMenuItemInjectionToken, +}); + +export default navigateToCatalogMenuItemInjectable; diff --git a/src/features/application-menu/main/menu-items/view/open-command-palette/open-command-palette-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/view/open-command-palette/open-command-palette-menu-item.injectable.ts new file mode 100644 index 0000000000..ac0bd77bfc --- /dev/null +++ b/src/features/application-menu/main/menu-items/view/open-command-palette/open-command-palette-menu-item.injectable.ts @@ -0,0 +1,40 @@ +/** + * 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 applicationMenuItemInjectionToken from "../../application-menu-item-injection-token"; +import broadcastMessageInjectable from "../../../../../../common/ipc/broadcast-message.injectable"; + +const openCommandPaletteMenuItemInjectable = getInjectable({ + id: "open-command-palette-menu-item", + + instantiate: (di) => { + const broadcastMessage = di.inject(broadcastMessageInjectable); + + return { + kind: "clickable-menu-item" as const, + parentId: "view", + id: "open-command-palette", + orderNumber: 20, + label: "Command Palette...", + keyboardShortcut: "Shift+CmdOrCtrl+P", + + onClick(_m, _b, event) { + /** + * Don't broadcast unless it was triggered by menu iteration so that + * there aren't double events in renderer + * + * NOTE: this `?` is required because of a bug in playwright. https://github.com/microsoft/playwright/issues/10554 + */ + if (!event?.triggeredByAccelerator) { + broadcastMessage("command-palette:open"); + } + }, + }; + }, + + injectionToken: applicationMenuItemInjectionToken, +}); + +export default openCommandPaletteMenuItemInjectable; diff --git a/src/features/application-menu/main/menu-items/view/operation-system-actions/operation-system-actions.injectable.ts b/src/features/application-menu/main/menu-items/view/operation-system-actions/operation-system-actions.injectable.ts new file mode 100644 index 0000000000..a7969d94f7 --- /dev/null +++ b/src/features/application-menu/main/menu-items/view/operation-system-actions/operation-system-actions.injectable.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { + getApplicationMenuOperationSystemActionInjectable, +} from "../../get-application-menu-operation-system-action-injectable"; + +export const actionForToggleDevTools = getApplicationMenuOperationSystemActionInjectable({ + id: "toggle-dev-tools", + parentId: "view", + orderNumber: 70, + actionName: "toggleDevTools", +}); + +export const actionForResetZoom = getApplicationMenuOperationSystemActionInjectable({ + id: "reset-zoom", + parentId: "view", + orderNumber: 90, + actionName: "resetZoom", +}); + +export const actionForZoomIn = getApplicationMenuOperationSystemActionInjectable({ + id: "zoom-in", + parentId: "view", + orderNumber: 100, + actionName: "zoomIn", +}); + +export const actionForZoomOut = getApplicationMenuOperationSystemActionInjectable({ + id: "zoom-out", + parentId: "view", + orderNumber: 110, + actionName: "zoomOut", +}); + +export const actionForToggleFullScreen = getApplicationMenuOperationSystemActionInjectable({ + id: "toggle-full-screen", + parentId: "view", + orderNumber: 130, + actionName: "togglefullscreen", +}); diff --git a/src/features/application-menu/main/menu-items/view/reload/reload-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/view/reload/reload-menu-item.injectable.ts new file mode 100644 index 0000000000..683daf666a --- /dev/null +++ b/src/features/application-menu/main/menu-items/view/reload/reload-menu-item.injectable.ts @@ -0,0 +1,34 @@ +/** + * 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 applicationMenuItemInjectionToken from "../../application-menu-item-injection-token"; +import reloadCurrentApplicationWindowInjectable from "../../../../../../main/start-main-application/lens-window/reload-current-application-window.injectable"; + +const reloadMenuItemInjectable = getInjectable({ + id: "reload-menu-item", + + instantiate: (di) => { + const reloadApplicationWindow = di.inject( + reloadCurrentApplicationWindowInjectable, + ); + + return { + kind: "clickable-menu-item" as const, + parentId: "view", + id: "reload", + orderNumber: 60, + label: "Reload", + keyboardShortcut: "CmdOrCtrl+R", + + onClick: () => { + reloadApplicationWindow(); + }, + }; + }, + + injectionToken: applicationMenuItemInjectionToken, +}); + +export default reloadMenuItemInjectable; diff --git a/src/features/application-menu/main/menu-items/view/separators/separators.injectable.ts b/src/features/application-menu/main/menu-items/view/separators/separators.injectable.ts new file mode 100644 index 0000000000..00584ea384 --- /dev/null +++ b/src/features/application-menu/main/menu-items/view/separators/separators.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { + getApplicationMenuSeparatorInjectable, +} from "../../get-application-menu-separator-injectable"; + +export const separator1 = getApplicationMenuSeparatorInjectable({ + id: "separator-1-for-view", + parentId: "view", + orderNumber: 30, +}); + +export const separator2 = getApplicationMenuSeparatorInjectable({ + id: "separator-2-for-view", + parentId: "view", + orderNumber: 80, +}); + +export const separator3 = getApplicationMenuSeparatorInjectable({ + id: "separator-3-for-view", + parentId: "view", + orderNumber: 120, +}); diff --git a/src/features/application-menu/main/menu-items/view/view-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/view/view-menu-item.injectable.ts new file mode 100644 index 0000000000..f45fbf7ebc --- /dev/null +++ b/src/features/application-menu/main/menu-items/view/view-menu-item.injectable.ts @@ -0,0 +1,22 @@ +/** + * 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 applicationMenuItemInjectionToken from "../application-menu-item-injection-token"; + +const viewMenuItemInjectable = getInjectable({ + id: "view-application-menu-item", + + instantiate: () => ({ + kind: "top-level-menu" as const, + parentId: "root" as const, + id: "view", + orderNumber: 40, + label: "View", + }), + + injectionToken: applicationMenuItemInjectionToken, +}); + +export default viewMenuItemInjectable; diff --git a/src/main/menu/menu-registration.ts b/src/features/application-menu/main/menu-registration.ts similarity index 100% rename from src/main/menu/menu-registration.ts rename to src/features/application-menu/main/menu-registration.ts diff --git a/src/features/application-menu/main/menu.ts b/src/features/application-menu/main/menu.ts new file mode 100644 index 0000000000..2082a35ed8 --- /dev/null +++ b/src/features/application-menu/main/menu.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { Menu } from "electron"; +import type { MenuItemOpts } from "./application-menu-items.injectable"; + +export type MenuTopId = "mac" | "file" | "edit" | "view" | "help"; + +export function buildMenu(applicationMenuItems: MenuItemOpts[]) { + Menu.setApplicationMenu( + Menu.buildFromTemplate(applicationMenuItems), + ); +} diff --git a/src/features/application-menu/main/populate-application-menu.global-override-for-injectable.ts b/src/features/application-menu/main/populate-application-menu.global-override-for-injectable.ts new file mode 100644 index 0000000000..acd4f5f14e --- /dev/null +++ b/src/features/application-menu/main/populate-application-menu.global-override-for-injectable.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import populateApplicationMenuInjectable from "./populate-application-menu.injectable"; +import { getGlobalOverride } from "../../../common/test-utils/get-global-override"; + +export default getGlobalOverride(populateApplicationMenuInjectable, () => () => {}); diff --git a/src/features/application-menu/main/populate-application-menu.injectable.ts b/src/features/application-menu/main/populate-application-menu.injectable.ts new file mode 100644 index 0000000000..8d744b4ded --- /dev/null +++ b/src/features/application-menu/main/populate-application-menu.injectable.ts @@ -0,0 +1,110 @@ +/** + * 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 { Menu } from "electron"; +import type { MenuItemOpts } from "./application-menu-items.injectable"; +import type { Composite } from "../../../common/utils/composite/get-composite/get-composite"; +import type { ApplicationMenuItemTypes } from "./menu-items/application-menu-item-injection-token"; +import { pipeline } from "@ogre-tools/fp"; +import { map, sortBy } from "lodash/fp"; +import type { MenuItemRoot } from "./application-menu-item-composite.injectable"; +import { checkThatAllDiscriminablesAreExhausted } from "../../../common/utils/composable-responsibilities/discriminable/discriminable"; + +const populateApplicationMenuInjectable = getInjectable({ + id: "populate-application-menu", + + instantiate: + () => (composite: Composite) => { + const electronTemplate = getApplicationMenuTemplate(composite); + const menu = Menu.buildFromTemplate(electronTemplate); + + Menu.setApplicationMenu(menu); + }, + + causesSideEffects: true, +}); + +export default populateApplicationMenuInjectable; + +export const getApplicationMenuTemplate = (composite: Composite) => { + const topLevelMenus = composite.children.filter( + (x): x is Composite => x.value.kind !== "root", + ); + + return topLevelMenus.map(toHierarchicalElectronMenuItem); +}; + +const toHierarchicalElectronMenuItem = ( + composite: Composite, +): MenuItemOpts => { + const value = composite.value; + + switch (value.kind) { + case "top-level-menu": { + const { id } = composite; + const { label, role } = value; + + return { + ...(id ? { id } : {}), + ...(role ? { role } : {}), + label, + + submenu: pipeline( + composite.children, + sortBy((childComposite) => childComposite.value.orderNumber), + map(toHierarchicalElectronMenuItem), + ), + }; + } + + case "sub-menu": { + const { id } = composite; + const { label } = value; + + return { + ...(id ? { id } : {}), + label, + + submenu: pipeline( + composite.children, + sortBy((childComposite) => childComposite.value.orderNumber), + map(toHierarchicalElectronMenuItem), + ), + }; + } + + case "clickable-menu-item": { + const { id } = composite; + const { label, onClick, keyboardShortcut } = value; + + return { + ...(id ? { id } : {}), + ...(label ? { label } : {}), + ...(keyboardShortcut ? { accelerator: keyboardShortcut }: {}), + click: onClick, + }; + } + + case "os-action-menu-item": { + const { label, keyboardShortcut, actionName } = value; + + return { + ...(label ? { label } : {}), + ...(keyboardShortcut ? { accelerator: keyboardShortcut } : {}), + role: actionName, + }; + } + + case "separator": { + return { + type: "separator", + }; + } + + default: { + throw checkThatAllDiscriminablesAreExhausted(value); + } + } +}; diff --git a/src/main/menu/start-application-menu.injectable.ts b/src/features/application-menu/main/start-application-menu.injectable.ts similarity index 64% rename from src/main/menu/start-application-menu.injectable.ts rename to src/features/application-menu/main/start-application-menu.injectable.ts index b241137cec..77fb31cb2a 100644 --- a/src/main/menu/start-application-menu.injectable.ts +++ b/src/features/application-menu/main/start-application-menu.injectable.ts @@ -3,18 +3,19 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import applicationMenuInjectable from "./application-menu.injectable"; -import { onLoadOfApplicationInjectionToken } from "../start-main-application/runnable-tokens/on-load-of-application-injection-token"; +import applicationMenuReactivityInjectable from "./application-menu-reactivity.injectable"; +import { onLoadOfApplicationInjectionToken } from "../../../main/start-main-application/runnable-tokens/on-load-of-application-injection-token"; const startApplicationMenuInjectable = getInjectable({ id: "start-application-menu", instantiate: (di) => { const applicationMenu = di.inject( - applicationMenuInjectable, + applicationMenuReactivityInjectable, ); return { + id: "start-application-menu", run: async () => { await applicationMenu.start(); }, diff --git a/src/main/menu/stop-application-menu.injectable.ts b/src/features/application-menu/main/stop-application-menu.injectable.ts similarity index 53% rename from src/main/menu/stop-application-menu.injectable.ts rename to src/features/application-menu/main/stop-application-menu.injectable.ts index 1492da32de..62abd439df 100644 --- a/src/main/menu/stop-application-menu.injectable.ts +++ b/src/features/application-menu/main/stop-application-menu.injectable.ts @@ -3,21 +3,18 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import applicationMenuInjectable from "./application-menu.injectable"; -import { beforeQuitOfBackEndInjectionToken } from "../start-main-application/runnable-tokens/before-quit-of-back-end-injection-token"; +import applicationMenuReactivityInjectable from "./application-menu-reactivity.injectable"; +import { beforeQuitOfBackEndInjectionToken } from "../../../main/start-main-application/runnable-tokens/before-quit-of-back-end-injection-token"; const stopApplicationMenuInjectable = getInjectable({ id: "stop-application-menu", instantiate: (di) => { - const applicationMenu = di.inject( - applicationMenuInjectable, - ); + const applicationMenu = di.inject(applicationMenuReactivityInjectable); return { - run: async () => { - await applicationMenu.stop(); - }, + id: "stop-application-menu", + run: () => void applicationMenu.stop(), }; }, diff --git a/src/features/application-update/__snapshots__/installing-update.test.ts.snap b/src/features/application-update/__snapshots__/installing-update.test.ts.snap index 394dc9c96c..395670cdda 100644 --- a/src/features/application-update/__snapshots__/installing-update.test.ts.snap +++ b/src/features/application-update/__snapshots__/installing-update.test.ts.snap @@ -12,44 +12,61 @@ exports[`installing update when started renders 1`] = `
- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -184,6 +201,7 @@ exports[`installing update when started renders 1`] = `

- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -386,6 +421,7 @@ exports[`installing update when started when user checks for updates renders 1`]

- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -588,6 +641,7 @@ exports[`installing update when started when user checks for updates when new up

- - - home - - - - - arrow_back - - - - - arrow_forward - - - +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+ +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -808,6 +886,7 @@ exports[`installing update when started when user checks for updates when new up

- - - home - - - - - arrow_back - - - - - arrow_forward - - - +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+ +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -1028,6 +1131,7 @@ exports[`installing update when started when user checks for updates when new up

- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -1230,6 +1351,7 @@ exports[`installing update when started when user checks for updates when no new

{ let builder: ApplicationBuilder; @@ -36,7 +36,7 @@ describe("analytics for installing update", () => { analyticsListenerMock = jest.fn(); builder.beforeApplicationStart(mainDi => { - mainDi.override(appVersionInjectable, () => "42.0.0"); + mainDi.override(getBuildVersionInjectable, () => () => "42.0.0"); checkForPlatformUpdatesMock = asyncFn(); @@ -142,7 +142,7 @@ describe("analytics for installing update", () => { it("when checking for updates using application menu, sends event to analytics for being checked from application menu", async () => { analyticsListenerMock.mockClear(); - builder.applicationMenu.click("root.check-for-updates"); + builder.applicationMenu.click("root", "mac", "check-for-updates"); expect(analyticsListenerMock.mock.calls).toEqual([ [ diff --git a/src/features/application-update/child-features/application-update-using-application-menu/main/check-for-updates-menu-item.injectable.ts b/src/features/application-update/child-features/application-update-using-application-menu/main/check-for-updates-menu-item.injectable.ts new file mode 100644 index 0000000000..fe997e62fb --- /dev/null +++ b/src/features/application-update/child-features/application-update-using-application-menu/main/check-for-updates-menu-item.injectable.ts @@ -0,0 +1,46 @@ +/** + * 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 applicationMenuItemInjectionToken from "../../../../application-menu/main/menu-items/application-menu-item-injection-token"; +import processCheckingForUpdatesInjectable from "../../../main/process-checking-for-updates.injectable"; +import showApplicationWindowInjectable from "../../../../../main/start-main-application/lens-window/show-application-window.injectable"; +import updatingIsEnabledInjectable from "../../../main/updating-is-enabled/updating-is-enabled.injectable"; +import isMacInjectable from "../../../../../common/vars/is-mac.injectable"; + +const checkForUpdatesMenuItemInjectable = getInjectable({ + id: "check-for-updates-menu-item", + + instantiate: (di) => { + const processCheckingForUpdates = di.inject( + processCheckingForUpdatesInjectable, + ); + + const showApplicationWindow = di.inject(showApplicationWindowInjectable); + + const updatingIsEnabled = di.inject(updatingIsEnabledInjectable); + const isMac = di.inject(isMacInjectable); + + return { + kind: "clickable-menu-item" as const, + id: "check-for-updates", + parentId: isMac ? "mac" : "help", + orderNumber: isMac ? 20 : 50, + label: "Check for updates", + isShown: updatingIsEnabled, + + onClick: () => { + // Todo: implement using async/await + processCheckingForUpdates("application-menu").then(() => + showApplicationWindow(), + ); + }, + }; + }, + + injectionToken: applicationMenuItemInjectionToken, +}); + +export default checkForUpdatesMenuItemInjectable; diff --git a/src/features/application-update/__snapshots__/installing-update-using-topbar-button.test.tsx.snap b/src/features/application-update/child-features/application-update-using-top-bar/__snapshots__/installing-update-using-topbar-button.test.tsx.snap similarity index 74% rename from src/features/application-update/__snapshots__/installing-update-using-topbar-button.test.tsx.snap rename to src/features/application-update/child-features/application-update-using-top-bar/__snapshots__/installing-update-using-topbar-button.test.tsx.snap index ec1935b053..dab5bce6ef 100644 --- a/src/features/application-update/__snapshots__/installing-update-using-topbar-button.test.tsx.snap +++ b/src/features/application-update/child-features/application-update-using-top-bar/__snapshots__/installing-update-using-topbar-button.test.tsx.snap @@ -12,62 +12,86 @@ exports[`encourage user to update when sufficient time passed since update was d
- - - home - - - - - arrow_back - - - - - arrow_forward - - - +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+ +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -202,6 +226,7 @@ exports[`encourage user to update when sufficient time passed since update was d

- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -404,6 +446,7 @@ exports[`encourage user to update when sufficient time passed since update was d

{ + const warningLevel = di.inject(updateWarningLevelInjectable); + + return { + id: "update-application", + isShown: computed(() => !!warningLevel.get()), + orderNumber: 50, + Component: UpdateButton, + }; + }, + + injectionToken: topBarItemOnLeftSideInjectionToken, +}); + +export default updateApplicationTopBarItemInjectable; diff --git a/src/renderer/components/update-button/index.ts b/src/features/application-update/child-features/application-update-using-top-bar/renderer/update-application-top-bar-item/update-button/index.ts similarity index 100% rename from src/renderer/components/update-button/index.ts rename to src/features/application-update/child-features/application-update-using-top-bar/renderer/update-application-top-bar-item/update-button/index.ts diff --git a/src/renderer/components/update-button/styles.module.scss b/src/features/application-update/child-features/application-update-using-top-bar/renderer/update-application-top-bar-item/update-button/styles.module.scss similarity index 100% rename from src/renderer/components/update-button/styles.module.scss rename to src/features/application-update/child-features/application-update-using-top-bar/renderer/update-application-top-bar-item/update-button/styles.module.scss diff --git a/src/renderer/components/update-button/update-button.tsx b/src/features/application-update/child-features/application-update-using-top-bar/renderer/update-application-top-bar-item/update-button/update-button.tsx similarity index 80% rename from src/renderer/components/update-button/update-button.tsx rename to src/features/application-update/child-features/application-update-using-top-bar/renderer/update-application-top-bar-item/update-button/update-button.tsx index 029b58f3fe..317e64936b 100644 --- a/src/renderer/components/update-button/update-button.tsx +++ b/src/features/application-update/child-features/application-update-using-top-bar/renderer/update-application-top-bar-item/update-button/update-button.tsx @@ -7,15 +7,15 @@ import styles from "./styles.module.scss"; import type { HTMLAttributes } from "react"; import React, { useState } from "react"; -import { Menu, MenuItem } from "../menu"; -import { cssNames } from "../../utils"; -import type { IconProps } from "../icon"; -import { Icon } from "../icon"; +import { Menu, MenuItem } from "../../../../../../../renderer/components/menu"; +import { cssNames } from "../../../../../../../renderer/utils"; +import type { IconProps } from "../../../../../../../renderer/components/icon"; +import { Icon } from "../../../../../../../renderer/components/icon"; import { withInjectables } from "@ogre-tools/injectable-react"; import { observer } from "mobx-react"; import type { IComputedValue } from "mobx"; -import restartAndInstallUpdateInjectable from "./restart-and-install-update.injectable"; -import updateWarningLevelInjectable from "./update-warning-level.injectable"; +import restartAndInstallUpdateInjectable from "../../../../../renderer/restart-and-install-update.injectable"; +import updateWarningLevelInjectable from "../update-warning-level.injectable"; interface UpdateButtonProps extends HTMLAttributes {} @@ -34,10 +34,6 @@ export const NonInjectedUpdateButton = observer(({ warningLevel, update, id }: U setOpened(!opened); }; - if (!level) { - return null; - } - return ( <>
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -184,6 +201,7 @@ exports[`installing update using tray when started renders 1`] = `

- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -386,6 +421,7 @@ exports[`installing update using tray when started when user checks for updates

- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -588,6 +641,7 @@ exports[`installing update using tray when started when user checks for updates

- - - home - - - - - arrow_back - - - - - arrow_forward - - - +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+ +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -808,6 +886,7 @@ exports[`installing update using tray when started when user checks for updates

- - - home - - - - - arrow_back - - - - - arrow_forward - - - +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+ +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -1028,6 +1131,7 @@ exports[`installing update using tray when started when user checks for updates

- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -1230,6 +1351,7 @@ exports[`installing update using tray when started when user checks for updates

{ let builder: ApplicationBuilder; diff --git a/src/main/application-update/tray-icons/checking-for-updates-tray-icon.injectable.ts b/src/features/application-update/child-features/application-update-using-tray/main/tray-icons/checking-for-updates-tray-icon.injectable.ts similarity index 66% rename from src/main/application-update/tray-icons/checking-for-updates-tray-icon.injectable.ts rename to src/features/application-update/child-features/application-update-using-tray/main/tray-icons/checking-for-updates-tray-icon.injectable.ts index 5d22c98bba..c955199c21 100644 --- a/src/main/application-update/tray-icons/checking-for-updates-tray-icon.injectable.ts +++ b/src/features/application-update/child-features/application-update-using-tray/main/tray-icons/checking-for-updates-tray-icon.injectable.ts @@ -4,10 +4,10 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { computed } from "mobx"; -import getTrayIconPathInjectable from "../../tray/menu-icon/get-tray-icon-path.injectable"; -import { trayIconInjectionToken } from "../../tray/menu-icon/tray-icon-injection-token"; -import updatesAreBeingDiscoveredInjectable from "../../../common/application-update/updates-are-being-discovered/updates-are-being-discovered.injectable"; -import updateIsBeingDownloadedInjectable from "../../../common/application-update/update-is-being-downloaded/update-is-being-downloaded.injectable"; +import getTrayIconPathInjectable from "../../../../../../main/tray/menu-icon/get-tray-icon-path.injectable"; +import { trayIconInjectionToken } from "../../../../../../main/tray/menu-icon/tray-icon-injection-token"; +import updatesAreBeingDiscoveredInjectable from "../../../../common/updates-are-being-discovered/updates-are-being-discovered.injectable"; +import updateIsBeingDownloadedInjectable from "../../../../common/update-is-being-downloaded/update-is-being-downloaded.injectable"; const checkingForUpdatesTrayIconInjectable = getInjectable({ id: "checking-for-updates-tray-icon", diff --git a/src/main/application-update/tray-icons/update-is-ready-to-be-installed-tray-icon.injectable.ts b/src/features/application-update/child-features/application-update-using-tray/main/tray-icons/update-is-ready-to-be-installed-tray-icon.injectable.ts similarity index 79% rename from src/main/application-update/tray-icons/update-is-ready-to-be-installed-tray-icon.injectable.ts rename to src/features/application-update/child-features/application-update-using-tray/main/tray-icons/update-is-ready-to-be-installed-tray-icon.injectable.ts index 7f097c0cbb..19d0495c38 100644 --- a/src/main/application-update/tray-icons/update-is-ready-to-be-installed-tray-icon.injectable.ts +++ b/src/features/application-update/child-features/application-update-using-tray/main/tray-icons/update-is-ready-to-be-installed-tray-icon.injectable.ts @@ -3,8 +3,8 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import getTrayIconPathInjectable from "../../tray/menu-icon/get-tray-icon-path.injectable"; -import { trayIconInjectionToken } from "../../tray/menu-icon/tray-icon-injection-token"; +import getTrayIconPathInjectable from "../../../../../../main/tray/menu-icon/get-tray-icon-path.injectable"; +import { trayIconInjectionToken } from "../../../../../../main/tray/menu-icon/tray-icon-injection-token"; import updateIsReadyToBeInstalledInjectable from "../update-is-ready-to-be-installed.injectable"; const updateIsReadyToBeInstalledTrayIconInjectable = getInjectable({ diff --git a/src/main/application-update/check-for-updates-tray-item.injectable.ts b/src/features/application-update/child-features/application-update-using-tray/main/tray-items/check-for-updates-tray-item.injectable.ts similarity index 66% rename from src/main/application-update/check-for-updates-tray-item.injectable.ts rename to src/features/application-update/child-features/application-update-using-tray/main/tray-items/check-for-updates-tray-item.injectable.ts index 82bdbfbbd4..e904abaa99 100644 --- a/src/main/application-update/check-for-updates-tray-item.injectable.ts +++ b/src/features/application-update/child-features/application-update-using-tray/main/tray-items/check-for-updates-tray-item.injectable.ts @@ -4,18 +4,18 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { computed } from "mobx"; -import updatingIsEnabledInjectable from "./updating-is-enabled.injectable"; -import { trayMenuItemInjectionToken } from "../tray/tray-menu-item/tray-menu-item-injection-token"; -import showApplicationWindowInjectable from "../start-main-application/lens-window/show-application-window.injectable"; -import discoveredUpdateVersionInjectable from "../../common/application-update/discovered-update-version/discovered-update-version.injectable"; -import updateIsBeingDownloadedInjectable from "../../common/application-update/update-is-being-downloaded/update-is-being-downloaded.injectable"; -import updatesAreBeingDiscoveredInjectable from "../../common/application-update/updates-are-being-discovered/updates-are-being-discovered.injectable"; -import progressOfUpdateDownloadInjectable from "../../common/application-update/progress-of-update-download/progress-of-update-download.injectable"; +import updatingIsEnabledInjectable from "../../../../main/updating-is-enabled/updating-is-enabled.injectable"; +import { trayMenuItemInjectionToken } from "../../../../../../main/tray/tray-menu-item/tray-menu-item-injection-token"; +import showApplicationWindowInjectable from "../../../../../../main/start-main-application/lens-window/show-application-window.injectable"; +import discoveredUpdateVersionInjectable from "../../../../common/discovered-update-version/discovered-update-version.injectable"; +import updateIsBeingDownloadedInjectable from "../../../../common/update-is-being-downloaded/update-is-being-downloaded.injectable"; +import updatesAreBeingDiscoveredInjectable from "../../../../common/updates-are-being-discovered/updates-are-being-discovered.injectable"; +import progressOfUpdateDownloadInjectable from "../../../../common/progress-of-update-download/progress-of-update-download.injectable"; import assert from "assert"; -import processCheckingForUpdatesInjectable from "./check-for-updates/process-checking-for-updates.injectable"; -import { withErrorSuppression } from "../../common/utils/with-error-suppression/with-error-suppression"; +import processCheckingForUpdatesInjectable from "../../../../main/process-checking-for-updates.injectable"; +import { withErrorSuppression } from "../../../../../../common/utils/with-error-suppression/with-error-suppression"; import { pipeline } from "@ogre-tools/fp"; -import withErrorLoggingInjectable from "../../common/utils/with-error-logging/with-error-logging.injectable"; +import withErrorLoggingInjectable from "../../../../../../common/utils/with-error-logging/with-error-logging.injectable"; const checkForUpdatesTrayItemInjectable = getInjectable({ id: "check-for-updates-tray-item", diff --git a/src/main/application-update/install-application-update-tray-item.injectable.ts b/src/features/application-update/child-features/application-update-using-tray/main/tray-items/install-application-update-tray-item.injectable.ts similarity index 68% rename from src/main/application-update/install-application-update-tray-item.injectable.ts rename to src/features/application-update/child-features/application-update-using-tray/main/tray-items/install-application-update-tray-item.injectable.ts index ba71d5cfe7..86ae7d676f 100644 --- a/src/main/application-update/install-application-update-tray-item.injectable.ts +++ b/src/features/application-update/child-features/application-update-using-tray/main/tray-items/install-application-update-tray-item.injectable.ts @@ -4,13 +4,13 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { computed } from "mobx"; -import { trayMenuItemInjectionToken } from "../tray/tray-menu-item/tray-menu-item-injection-token"; -import discoveredUpdateVersionInjectable from "../../common/application-update/discovered-update-version/discovered-update-version.injectable"; -import { withErrorSuppression } from "../../common/utils/with-error-suppression/with-error-suppression"; +import { trayMenuItemInjectionToken } from "../../../../../../main/tray/tray-menu-item/tray-menu-item-injection-token"; +import discoveredUpdateVersionInjectable from "../../../../common/discovered-update-version/discovered-update-version.injectable"; +import { withErrorSuppression } from "../../../../../../common/utils/with-error-suppression/with-error-suppression"; import { pipeline } from "@ogre-tools/fp"; -import withErrorLoggingInjectable from "../../common/utils/with-error-logging/with-error-logging.injectable"; -import quitAndInstallUpdateInjectable from "./quit-and-install-update.injectable"; -import updateIsReadyToBeInstalledInjectable from "./update-is-ready-to-be-installed.injectable"; +import withErrorLoggingInjectable from "../../../../../../common/utils/with-error-logging/with-error-logging.injectable"; +import quitAndInstallUpdateInjectable from "../../../../main/quit-and-install-update.injectable"; +import updateIsReadyToBeInstalledInjectable from "../update-is-ready-to-be-installed.injectable"; const installApplicationUpdateTrayItemInjectable = getInjectable({ id: "install-update-tray-item", diff --git a/src/main/application-update/update-is-ready-to-be-installed.injectable.ts b/src/features/application-update/child-features/application-update-using-tray/main/update-is-ready-to-be-installed.injectable.ts similarity index 71% rename from src/main/application-update/update-is-ready-to-be-installed.injectable.ts rename to src/features/application-update/child-features/application-update-using-tray/main/update-is-ready-to-be-installed.injectable.ts index 157d1bbd6c..39e23f51e6 100644 --- a/src/main/application-update/update-is-ready-to-be-installed.injectable.ts +++ b/src/features/application-update/child-features/application-update-using-tray/main/update-is-ready-to-be-installed.injectable.ts @@ -4,8 +4,8 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { computed } from "mobx"; -import discoveredUpdateVersionInjectable from "../../common/application-update/discovered-update-version/discovered-update-version.injectable"; -import updateIsBeingDownloadedInjectable from "../../common/application-update/update-is-being-downloaded/update-is-being-downloaded.injectable"; +import discoveredUpdateVersionInjectable from "../../../common/discovered-update-version/discovered-update-version.injectable"; +import updateIsBeingDownloadedInjectable from "../../../common/update-is-being-downloaded/update-is-being-downloaded.injectable"; const updateIsReadyToBeInstalledInjectable = getInjectable({ id: "update-is-ready-to-be-installed", diff --git a/src/features/application-update/__snapshots__/force-user-to-update-when-too-long-time-since-update-was-downloaded.test.ts.snap b/src/features/application-update/child-features/force-user-to-update-when-too-long-time-since-update-was-downloaded/__snapshots__/force-user-to-update-when-too-long-time-since-update-was-downloaded.test.ts.snap similarity index 72% rename from src/features/application-update/__snapshots__/force-user-to-update-when-too-long-time-since-update-was-downloaded.test.ts.snap rename to src/features/application-update/child-features/force-user-to-update-when-too-long-time-since-update-was-downloaded/__snapshots__/force-user-to-update-when-too-long-time-since-update-was-downloaded.test.ts.snap index e6856ccc51..6ba121489b 100644 --- a/src/features/application-update/__snapshots__/force-user-to-update-when-too-long-time-since-update-was-downloaded.test.ts.snap +++ b/src/features/application-update/child-features/force-user-to-update-when-too-long-time-since-update-was-downloaded/__snapshots__/force-user-to-update-when-too-long-time-since-update-was-downloaded.test.ts.snap @@ -12,62 +12,86 @@ exports[`force user to update when too long since update was downloaded when app
- - - home - - - - - arrow_back - - - - - arrow_forward - - - +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+ +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -202,6 +226,7 @@ exports[`force user to update when too long since update was downloaded when app

- - - home - - - - - arrow_back - - - - - arrow_forward - - - +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+ +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -422,6 +471,7 @@ exports[`force user to update when too long since update was downloaded when app

- - - home - - - - - arrow_back - - - - - arrow_forward - - - +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+ +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -689,6 +763,7 @@ exports[`force user to update when too long since update was downloaded when app

({ + id: "force-update-modal", + Component: () => null, + shouldRender: computed(() => false), + }), +); diff --git a/src/renderer/application-update/force-update-modal/force-update-modal-root-frame-component.injectable.ts b/src/features/application-update/child-features/force-user-to-update-when-too-long-time-since-update-was-downloaded/renderer/force-update-modal/force-update-modal-root-frame-component.injectable.ts similarity index 83% rename from src/renderer/application-update/force-update-modal/force-update-modal-root-frame-component.injectable.ts rename to src/features/application-update/child-features/force-user-to-update-when-too-long-time-since-update-was-downloaded/renderer/force-update-modal/force-update-modal-root-frame-component.injectable.ts index 9681aa72e0..9109f4bfe3 100644 --- a/src/renderer/application-update/force-update-modal/force-update-modal-root-frame-component.injectable.ts +++ b/src/features/application-update/child-features/force-user-to-update-when-too-long-time-since-update-was-downloaded/renderer/force-update-modal/force-update-modal-root-frame-component.injectable.ts @@ -4,10 +4,10 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { computed } from "mobx"; -import { rootFrameChildComponentInjectionToken } from "../../frames/root-frame/root-frame-child-component-injection-token"; +import { rootFrameChildComponentInjectionToken } from "../../../../../../renderer/frames/root-frame/root-frame-child-component-injection-token"; import { ForceUpdateModal } from "./force-update-modal"; import timeSinceUpdateWasDownloadedInjectable from "./time-since-update-was-downloaded.injectable"; -import updateDownloadedDateTimeInjectable from "../../../common/application-update/update-downloaded-date-time/update-downloaded-date-time.injectable"; +import updateDownloadedDateTimeInjectable from "../../../../common/update-downloaded-date-time/update-downloaded-date-time.injectable"; import timeAfterUpdateMustBeInstalledInjectable from "./time-after-update-must-be-installed.injectable"; const forceUpdateModalRootFrameComponentInjectable = getInjectable({ diff --git a/src/renderer/application-update/force-update-modal/force-update-modal.module.scss b/src/features/application-update/child-features/force-user-to-update-when-too-long-time-since-update-was-downloaded/renderer/force-update-modal/force-update-modal.module.scss similarity index 100% rename from src/renderer/application-update/force-update-modal/force-update-modal.module.scss rename to src/features/application-update/child-features/force-user-to-update-when-too-long-time-since-update-was-downloaded/renderer/force-update-modal/force-update-modal.module.scss diff --git a/src/renderer/application-update/force-update-modal/force-update-modal.tsx b/src/features/application-update/child-features/force-user-to-update-when-too-long-time-since-update-was-downloaded/renderer/force-update-modal/force-update-modal.tsx similarity index 84% rename from src/renderer/application-update/force-update-modal/force-update-modal.tsx rename to src/features/application-update/child-features/force-user-to-update-when-too-long-time-since-update-was-downloaded/renderer/force-update-modal/force-update-modal.tsx index 22c322be82..83daa606e3 100644 --- a/src/renderer/application-update/force-update-modal/force-update-modal.tsx +++ b/src/features/application-update/child-features/force-user-to-update-when-too-long-time-since-update-was-downloaded/renderer/force-update-modal/force-update-modal.tsx @@ -4,13 +4,13 @@ */ import { withInjectables } from "@ogre-tools/injectable-react"; import React from "react"; -import restartAndInstallUpdateInjectable from "../../components/update-button/restart-and-install-update.injectable"; -import { Countdown } from "../../components/countdown/countdown"; +import restartAndInstallUpdateInjectable from "../../../../renderer/restart-and-install-update.injectable"; +import { Countdown } from "../../../../../../renderer/components/countdown/countdown"; import type { IComputedValue } from "mobx"; import { observer } from "mobx-react"; import installUpdateCountdownInjectable from "./install-update-countdown.injectable"; -import { Dialog } from "../../components/dialog"; -import { Button } from "../../components/button"; +import { Dialog } from "../../../../../../renderer/components/dialog"; +import { Button } from "../../../../../../renderer/components/button"; import styles from "./force-update-modal.module.scss"; interface Dependencies { diff --git a/src/renderer/application-update/force-update-modal/install-update-countdown.injectable.ts b/src/features/application-update/child-features/force-user-to-update-when-too-long-time-since-update-was-downloaded/renderer/force-update-modal/install-update-countdown.injectable.ts similarity index 78% rename from src/renderer/application-update/force-update-modal/install-update-countdown.injectable.ts rename to src/features/application-update/child-features/force-user-to-update-when-too-long-time-since-update-was-downloaded/renderer/force-update-modal/install-update-countdown.injectable.ts index eb1ee53371..f8e99b9ac1 100644 --- a/src/renderer/application-update/force-update-modal/install-update-countdown.injectable.ts +++ b/src/features/application-update/child-features/force-user-to-update-when-too-long-time-since-update-was-downloaded/renderer/force-update-modal/install-update-countdown.injectable.ts @@ -3,9 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import countdownStateInjectable from "../../components/countdown/countdown-state.injectable"; +import countdownStateInjectable from "../../../../../../renderer/components/countdown/countdown-state.injectable"; import secondsAfterInstallStartsInjectable from "./seconds-after-install-starts.injectable"; -import restartAndInstallUpdateInjectable from "../../components/update-button/restart-and-install-update.injectable"; +import restartAndInstallUpdateInjectable from "../../../../renderer/restart-and-install-update.injectable"; const installUpdateCountdownInjectable = getInjectable({ id: "install-update-countdown", diff --git a/src/renderer/application-update/force-update-modal/seconds-after-install-starts.injectable.ts b/src/features/application-update/child-features/force-user-to-update-when-too-long-time-since-update-was-downloaded/renderer/force-update-modal/seconds-after-install-starts.injectable.ts similarity index 100% rename from src/renderer/application-update/force-update-modal/seconds-after-install-starts.injectable.ts rename to src/features/application-update/child-features/force-user-to-update-when-too-long-time-since-update-was-downloaded/renderer/force-update-modal/seconds-after-install-starts.injectable.ts diff --git a/src/renderer/application-update/force-update-modal/time-after-update-must-be-installed.injectable.ts b/src/features/application-update/child-features/force-user-to-update-when-too-long-time-since-update-was-downloaded/renderer/force-update-modal/time-after-update-must-be-installed.injectable.ts similarity index 100% rename from src/renderer/application-update/force-update-modal/time-after-update-must-be-installed.injectable.ts rename to src/features/application-update/child-features/force-user-to-update-when-too-long-time-since-update-was-downloaded/renderer/force-update-modal/time-after-update-must-be-installed.injectable.ts diff --git a/src/renderer/application-update/force-update-modal/time-since-update-was-downloaded.injectable.ts b/src/features/application-update/child-features/force-user-to-update-when-too-long-time-since-update-was-downloaded/renderer/force-update-modal/time-since-update-was-downloaded.injectable.ts similarity index 78% rename from src/renderer/application-update/force-update-modal/time-since-update-was-downloaded.injectable.ts rename to src/features/application-update/child-features/force-user-to-update-when-too-long-time-since-update-was-downloaded/renderer/force-update-modal/time-since-update-was-downloaded.injectable.ts index e89657a72c..8ac86c80ca 100644 --- a/src/renderer/application-update/force-update-modal/time-since-update-was-downloaded.injectable.ts +++ b/src/features/application-update/child-features/force-user-to-update-when-too-long-time-since-update-was-downloaded/renderer/force-update-modal/time-since-update-was-downloaded.injectable.ts @@ -6,8 +6,8 @@ import { getInjectable } from "@ogre-tools/injectable"; import assert from "assert"; import { computed } from "mobx"; import moment from "moment"; -import updateDownloadedDateTimeInjectable from "../../../common/application-update/update-downloaded-date-time/update-downloaded-date-time.injectable"; -import { reactiveNow } from "../../../common/utils/reactive-now/reactive-now"; +import updateDownloadedDateTimeInjectable from "../../../../common/update-downloaded-date-time/update-downloaded-date-time.injectable"; +import { reactiveNow } from "../../../../../../common/utils/reactive-now/reactive-now"; const timeSinceUpdateWasDownloadedInjectable = getInjectable({ id: "time-since-update-was-downloaded", diff --git a/src/features/application-update/__snapshots__/periodical-checking-of-updates.test.ts.snap b/src/features/application-update/child-features/periodical-checking-of-updates/__snapshots__/periodical-checking-of-updates.test.ts.snap similarity index 76% rename from src/features/application-update/__snapshots__/periodical-checking-of-updates.test.ts.snap rename to src/features/application-update/child-features/periodical-checking-of-updates/__snapshots__/periodical-checking-of-updates.test.ts.snap index faae797792..36e52e2d5f 100644 --- a/src/features/application-update/__snapshots__/periodical-checking-of-updates.test.ts.snap +++ b/src/features/application-update/child-features/periodical-checking-of-updates/__snapshots__/periodical-checking-of-updates.test.ts.snap @@ -12,44 +12,61 @@ exports[`periodical checking of updates given updater is enabled and configurati
- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -184,6 +201,7 @@ exports[`periodical checking of updates given updater is enabled and configurati

({ + start: async () => {}, + stop: async () => {}, + started: false, +})); diff --git a/src/main/application-update/periodical-check-for-updates/periodical-check-for-updates.injectable.ts b/src/features/application-update/child-features/periodical-checking-of-updates/main/periodical-check-for-updates.injectable.ts similarity index 71% rename from src/main/application-update/periodical-check-for-updates/periodical-check-for-updates.injectable.ts rename to src/features/application-update/child-features/periodical-checking-of-updates/main/periodical-check-for-updates.injectable.ts index b6b5f7b852..85c687e478 100644 --- a/src/main/application-update/periodical-check-for-updates/periodical-check-for-updates.injectable.ts +++ b/src/features/application-update/child-features/periodical-checking-of-updates/main/periodical-check-for-updates.injectable.ts @@ -3,9 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { getStartableStoppable } from "../../../common/utils/get-startable-stoppable"; -import processCheckingForUpdatesInjectable from "../check-for-updates/process-checking-for-updates.injectable"; -import withOrphanPromiseInjectable from "../../../common/utils/with-orphan-promise/with-orphan-promise.injectable"; +import { getStartableStoppable } from "../../../../../common/utils/get-startable-stoppable"; +import processCheckingForUpdatesInjectable from "../../../main/process-checking-for-updates.injectable"; +import withOrphanPromiseInjectable from "../../../../../common/utils/with-orphan-promise/with-orphan-promise.injectable"; + +const TWO_HOURS = 1000 * 60 * 60 * 2; const periodicalCheckForUpdatesInjectable = getInjectable({ id: "periodical-check-for-updates", @@ -15,12 +17,9 @@ const periodicalCheckForUpdatesInjectable = getInjectable({ const processCheckingForUpdates = withOrphanPromise(di.inject(processCheckingForUpdatesInjectable)); return getStartableStoppable("periodical-check-for-updates", () => { - const TWO_HOURS = 1000 * 60 * 60 * 2; - processCheckingForUpdates("periodic"); const intervalId = setInterval(() => { - processCheckingForUpdates("periodic"); }, TWO_HOURS); diff --git a/src/main/application-update/periodical-check-for-updates/start-checking-for-updates.injectable.ts b/src/features/application-update/child-features/periodical-checking-of-updates/main/start-checking-for-updates.injectable.ts similarity index 73% rename from src/main/application-update/periodical-check-for-updates/start-checking-for-updates.injectable.ts rename to src/features/application-update/child-features/periodical-checking-of-updates/main/start-checking-for-updates.injectable.ts index 347015b584..0292d148df 100644 --- a/src/main/application-update/periodical-check-for-updates/start-checking-for-updates.injectable.ts +++ b/src/features/application-update/child-features/periodical-checking-of-updates/main/start-checking-for-updates.injectable.ts @@ -4,8 +4,8 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import periodicalCheckForUpdatesInjectable from "./periodical-check-for-updates.injectable"; -import updatingIsEnabledInjectable from "../updating-is-enabled.injectable"; -import { afterApplicationIsLoadedInjectionToken } from "../../start-main-application/runnable-tokens/after-application-is-loaded-injection-token"; +import updatingIsEnabledInjectable from "../../../main/updating-is-enabled/updating-is-enabled.injectable"; +import { afterApplicationIsLoadedInjectionToken } from "../../../../../main/start-main-application/runnable-tokens/after-application-is-loaded-injection-token"; const startCheckingForUpdatesInjectable = getInjectable({ id: "start-checking-for-updates", @@ -15,6 +15,7 @@ const startCheckingForUpdatesInjectable = getInjectable({ const updatingIsEnabled = di.inject(updatingIsEnabledInjectable); return { + id: "start-checking-for-updates", run: async () => { if (updatingIsEnabled && !periodicalCheckForUpdates.started) { await periodicalCheckForUpdates.start(); diff --git a/src/main/application-update/periodical-check-for-updates/stop-checking-for-updates.injectable.ts b/src/features/application-update/child-features/periodical-checking-of-updates/main/stop-checking-for-updates.injectable.ts similarity index 71% rename from src/main/application-update/periodical-check-for-updates/stop-checking-for-updates.injectable.ts rename to src/features/application-update/child-features/periodical-checking-of-updates/main/stop-checking-for-updates.injectable.ts index 944642b674..6e58d7dd77 100644 --- a/src/main/application-update/periodical-check-for-updates/stop-checking-for-updates.injectable.ts +++ b/src/features/application-update/child-features/periodical-checking-of-updates/main/stop-checking-for-updates.injectable.ts @@ -4,7 +4,7 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import periodicalCheckForUpdatesInjectable from "./periodical-check-for-updates.injectable"; -import { beforeQuitOfBackEndInjectionToken } from "../../start-main-application/runnable-tokens/before-quit-of-back-end-injection-token"; +import { beforeQuitOfBackEndInjectionToken } from "../../../../../main/start-main-application/runnable-tokens/before-quit-of-back-end-injection-token"; const stopCheckingForUpdatesInjectable = getInjectable({ id: "stop-checking-for-updates", @@ -13,10 +13,13 @@ const stopCheckingForUpdatesInjectable = getInjectable({ const periodicalCheckForUpdates = di.inject(periodicalCheckForUpdatesInjectable); return { - run: async () => { + id: "stop-checking-for-updates", + run: () => { if (periodicalCheckForUpdates.started) { - await periodicalCheckForUpdates.stop(); + periodicalCheckForUpdates.stop(); } + + return undefined; }, }; }, diff --git a/src/features/application-update/periodical-checking-of-updates.test.ts b/src/features/application-update/child-features/periodical-checking-of-updates/periodical-checking-of-updates.test.ts similarity index 80% rename from src/features/application-update/periodical-checking-of-updates.test.ts rename to src/features/application-update/child-features/periodical-checking-of-updates/periodical-checking-of-updates.test.ts index e5c7b1c17c..e38ebdcbfc 100644 --- a/src/features/application-update/periodical-checking-of-updates.test.ts +++ b/src/features/application-update/child-features/periodical-checking-of-updates/periodical-checking-of-updates.test.ts @@ -2,14 +2,14 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; -import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import type { ApplicationBuilder } from "../../../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../../../renderer/components/test-utils/get-application-builder"; import type { RenderResult } from "@testing-library/react"; -import electronUpdaterIsActiveInjectable from "../../main/electron-app/features/electron-updater-is-active.injectable"; -import publishIsConfiguredInjectable from "../../main/application-update/publish-is-configured.injectable"; -import processCheckingForUpdatesInjectable from "../../main/application-update/check-for-updates/process-checking-for-updates.injectable"; -import periodicalCheckForUpdatesInjectable from "../../main/application-update/periodical-check-for-updates/periodical-check-for-updates.injectable"; -import { advanceFakeTime, useFakeTime } from "../../common/test-utils/use-fake-time"; +import electronUpdaterIsActiveInjectable from "../../../../main/electron-app/features/electron-updater-is-active.injectable"; +import publishIsConfiguredInjectable from "../../main/updating-is-enabled/publish-is-configured/publish-is-configured.injectable"; +import processCheckingForUpdatesInjectable from "../../main/process-checking-for-updates.injectable"; +import periodicalCheckForUpdatesInjectable from "./main/periodical-check-for-updates.injectable"; +import { advanceFakeTime, useFakeTime } from "../../../../common/test-utils/use-fake-time"; const ENOUGH_TIME = 1000 * 60 * 60 * 2; diff --git a/src/features/application-update/child-features/preferences/renderer/update-channel/update-channel-preference-block.injectable.ts b/src/features/application-update/child-features/preferences/renderer/update-channel/update-channel-preference-block.injectable.ts new file mode 100644 index 0000000000..aa4fe3f5fb --- /dev/null +++ b/src/features/application-update/child-features/preferences/renderer/update-channel/update-channel-preference-block.injectable.ts @@ -0,0 +1,23 @@ +/** + * 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 { preferenceItemInjectionToken } from "../../../../../preferences/renderer/preference-items/preference-item-injection-token"; +import { UpdateChannel } from "./update-channel"; + +const updateChannelPreferenceBlockInjectable = getInjectable({ + id: "update-channel-preference-item", + + instantiate: () => ({ + kind: "block" as const, + id: "update-channel", + parentId: "application-page", + orderNumber: 50, + Component: UpdateChannel, + }), + + injectionToken: preferenceItemInjectionToken, +}); + +export default updateChannelPreferenceBlockInjectable; diff --git a/src/features/application-update/child-features/preferences/renderer/update-channel/update-channel.tsx b/src/features/application-update/child-features/preferences/renderer/update-channel/update-channel.tsx new file mode 100644 index 0000000000..339d54beb8 --- /dev/null +++ b/src/features/application-update/child-features/preferences/renderer/update-channel/update-channel.tsx @@ -0,0 +1,51 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import React from "react"; +import { SubTitle } from "../../../../../../renderer/components/layout/sub-title"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import { Select } from "../../../../../../renderer/components/select"; +import { updateChannels } from "../../../../common/update-channels"; +import type { SelectedUpdateChannel } from "../../../../common/selected-update-channel/selected-update-channel.injectable"; +import selectedUpdateChannelInjectable from "../../../../common/selected-update-channel/selected-update-channel.injectable"; +import { pipeline } from "@ogre-tools/fp"; +import { map, toPairs } from "lodash/fp"; +import { observer } from "mobx-react"; + +interface Dependencies { + selectedUpdateChannel: SelectedUpdateChannel; +} + +const updateChannelOptions = pipeline( + toPairs(updateChannels), + + map(([, channel]) => ({ + value: channel.id, + label: channel.label, + })), +); + + +const NonInjectedUpdateChannel = observer(({ selectedUpdateChannel }: Dependencies) => ( +
+ + +
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+ +
+ test modal +
+
+`; diff --git a/src/features/cluster/__snapshots__/order-of-sidebar-items.test.tsx.snap b/src/features/cluster/__snapshots__/order-of-sidebar-items.test.tsx.snap index 7f76811494..8a293a41f9 100644 --- a/src/features/cluster/__snapshots__/order-of-sidebar-items.test.tsx.snap +++ b/src/features/cluster/__snapshots__/order-of-sidebar-items.test.tsx.snap @@ -484,6 +484,7 @@ exports[`cluster - order of sidebar items when rendered renders 1`] = ` > -
+
Close ⌘+W
@@ -1096,6 +1099,7 @@ exports[`cluster - order of sidebar items when rendered when parent is expanded > -
+
Close ⌘+W
diff --git a/src/features/cluster/__snapshots__/sidebar-and-tab-navigation-for-core.test.tsx.snap b/src/features/cluster/__snapshots__/sidebar-and-tab-navigation-for-core.test.tsx.snap index 49fcc917a4..6d64889cff 100644 --- a/src/features/cluster/__snapshots__/sidebar-and-tab-navigation-for-core.test.tsx.snap +++ b/src/features/cluster/__snapshots__/sidebar-and-tab-navigation-for-core.test.tsx.snap @@ -457,6 +457,7 @@ exports[`cluster - sidebar and tab navigation for core given core registrations > -
+
Close ⌘+W
@@ -990,6 +993,7 @@ exports[`cluster - sidebar and tab navigation for core given core registrations > -
+
Close ⌘+W
@@ -1543,6 +1549,7 @@ exports[`cluster - sidebar and tab navigation for core given core registrations > -
+
Close ⌘+W
@@ -1976,6 +1985,7 @@ exports[`cluster - sidebar and tab navigation for core given core registrations > -
+
Close ⌘+W
@@ -2388,6 +2400,7 @@ exports[`cluster - sidebar and tab navigation for core given core registrations > -
+
Close ⌘+W
@@ -2941,6 +2956,7 @@ exports[`cluster - sidebar and tab navigation for core given core registrations > -
+
Close ⌘+W
@@ -3474,6 +3492,7 @@ exports[`cluster - sidebar and tab navigation for core given core registrations > -
+
Close ⌘+W
diff --git a/src/features/cluster/__snapshots__/sidebar-and-tab-navigation-for-extensions.test.tsx.snap b/src/features/cluster/__snapshots__/sidebar-and-tab-navigation-for-extensions.test.tsx.snap index fff3da0126..ac415ae3e2 100644 --- a/src/features/cluster/__snapshots__/sidebar-and-tab-navigation-for-extensions.test.tsx.snap +++ b/src/features/cluster/__snapshots__/sidebar-and-tab-navigation-for-extensions.test.tsx.snap @@ -457,6 +457,7 @@ exports[`cluster - sidebar and tab navigation for extensions given extension wit > -
+
Close ⌘+W
@@ -990,6 +993,7 @@ exports[`cluster - sidebar and tab navigation for extensions given extension wit > -
+
Close ⌘+W
@@ -1559,6 +1565,7 @@ exports[`cluster - sidebar and tab navigation for extensions given extension wit > -
+
Close ⌘+W
@@ -2047,6 +2056,7 @@ exports[`cluster - sidebar and tab navigation for extensions given extension wit > -
+
Close ⌘+W
@@ -2535,6 +2547,7 @@ exports[`cluster - sidebar and tab navigation for extensions given extension wit > -
+
Close ⌘+W
@@ -2986,6 +3001,7 @@ exports[`cluster - sidebar and tab navigation for extensions given extension wit > -
+
Close ⌘+W
@@ -3555,6 +3573,7 @@ exports[`cluster - sidebar and tab navigation for extensions given extension wit > -
+
Close ⌘+W
@@ -4088,6 +4109,7 @@ exports[`cluster - sidebar and tab navigation for extensions given extension wit > -
+
Close ⌘+W
diff --git a/src/features/cluster/__snapshots__/visibility-of-sidebar-items.test.tsx.snap b/src/features/cluster/__snapshots__/visibility-of-sidebar-items.test.tsx.snap index ed36d5134f..7fe8edf8cf 100644 --- a/src/features/cluster/__snapshots__/visibility-of-sidebar-items.test.tsx.snap +++ b/src/features/cluster/__snapshots__/visibility-of-sidebar-items.test.tsx.snap @@ -429,6 +429,7 @@ exports[`cluster - visibility of sidebar items given kube resource for route is > -
+
Close ⌘+W
@@ -974,6 +977,7 @@ exports[`cluster - visibility of sidebar items given kube resource for route is > -
+
Close ⌘+W
diff --git a/src/features/cluster/__snapshots__/workload-overview.test.tsx.snap b/src/features/cluster/__snapshots__/workload-overview.test.tsx.snap index 6dbe47992f..e53ace250f 100644 --- a/src/features/cluster/__snapshots__/workload-overview.test.tsx.snap +++ b/src/features/cluster/__snapshots__/workload-overview.test.tsx.snap @@ -484,6 +484,7 @@ exports[`workload overview when navigating to workload overview renders 1`] = ` > -
+
Close ⌘+W
diff --git a/src/features/cluster/delete-dialog/__snapshots__/delete-cluster-dialog.test.tsx.snap b/src/features/cluster/delete-dialog/__snapshots__/delete-cluster-dialog.test.tsx.snap new file mode 100644 index 0000000000..f6dff1fd8c --- /dev/null +++ b/src/features/cluster/delete-dialog/__snapshots__/delete-cluster-dialog.test.tsx.snap @@ -0,0 +1,2287 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Deleting a cluster when an internal kubeconfig cluster is used when the dialog is opened renders 1`] = ` + +
+
+
+
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+
+
+
+
+
+
+
+
+ + + arrow_left + + +
+
+ 0 +
+
+ + + arrow_right + + +
+
+
+
+
+
+
+
+
+ + +`; + +exports[`Deleting a cluster when the kubeconfig has multiple clusters when the dialog is opened for not the current cluster renders 1`] = ` + +
+
+
+
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+
+
+
+
+
+
+
+
+ + + arrow_left + + +
+
+ 0 +
+
+ + + arrow_right + + +
+
+
+
+
+
+
+
+
+ + +`; + +exports[`Deleting a cluster when the kubeconfig has multiple clusters when the dialog is opened for not the current cluster when context switching checkbox is clicked renders 1`] = ` + +
+
+
+
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+
+
+
+
+
+
+
+
+ + + arrow_left + + +
+
+ 0 +
+
+ + + arrow_right + + +
+
+
+
+
+
+
+
+
+ + +`; + +exports[`Deleting a cluster when the kubeconfig has multiple clusters when the dialog is opened for the current cluster renders 1`] = ` + +
+
+
+
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+
+
+
+
+
+
+
+
+ + + arrow_left + + +
+
+ 0 +
+
+ + + arrow_right + + +
+
+
+
+
+
+
+
+
+ + +`; + +exports[`Deleting a cluster when the kubeconfig has only one cluster when the dialog is opened renders 1`] = ` + +
+
+
+
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+
+
+
+
+
+
+
+
+ + + arrow_left + + +
+
+ 0 +
+
+ + + arrow_right + + +
+
+
+
+
+
+
+
+
+ + +`; diff --git a/src/features/cluster/delete-dialog/common/clear-as-deleting-channel.ts b/src/features/cluster/delete-dialog/common/clear-as-deleting-channel.ts new file mode 100644 index 0000000000..bf33a23165 --- /dev/null +++ b/src/features/cluster/delete-dialog/common/clear-as-deleting-channel.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { ClusterId } from "../../../../common/cluster-types"; +import type { RequestChannel } from "../../../../common/utils/channel/request-channel-listener-injection-token"; + +export type ClearClusterAsDeletingChannel = RequestChannel; + +export const clearClusterAsDeletingChannel: ClearClusterAsDeletingChannel = { + id: "clear-cluster-as-deleting", +}; diff --git a/src/features/cluster/delete-dialog/common/delete-channel.ts b/src/features/cluster/delete-dialog/common/delete-channel.ts new file mode 100644 index 0000000000..0e9142fcd3 --- /dev/null +++ b/src/features/cluster/delete-dialog/common/delete-channel.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { ClusterId } from "../../../../common/cluster-types"; +import type { RequestChannel } from "../../../../common/utils/channel/request-channel-listener-injection-token"; + +export type DeleteClusterChannel = RequestChannel; + +export const deleteClusterChannel: DeleteClusterChannel = { + id: "delete-cluster", +}; diff --git a/src/features/cluster/delete-dialog/common/set-as-deleting-channel.ts b/src/features/cluster/delete-dialog/common/set-as-deleting-channel.ts new file mode 100644 index 0000000000..57ef2e3a8d --- /dev/null +++ b/src/features/cluster/delete-dialog/common/set-as-deleting-channel.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { ClusterId } from "../../../../common/cluster-types"; +import type { RequestChannel } from "../../../../common/utils/channel/request-channel-listener-injection-token"; + +export type SetClusterAsDeletingChannel = RequestChannel; + +export const setClusterAsDeletingChannel: SetClusterAsDeletingChannel = { + id: "set-cluster-as-deleting", +}; diff --git a/src/features/cluster/delete-dialog/delete-cluster-dialog.test.tsx b/src/features/cluster/delete-dialog/delete-cluster-dialog.test.tsx new file mode 100644 index 0000000000..c96f2714e1 --- /dev/null +++ b/src/features/cluster/delete-dialog/delete-cluster-dialog.test.tsx @@ -0,0 +1,280 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import "@testing-library/jest-dom/extend-expect"; +import { KubeConfig } from "@kubernetes/client-node"; +import type { RenderResult } from "@testing-library/react"; +import type { CreateCluster } from "../../../common/cluster/create-cluster-injection-token"; +import { createClusterInjectionToken } from "../../../common/cluster/create-cluster-injection-token"; +import createContextHandlerInjectable from "../../../main/context-handler/create-context-handler.injectable"; +import createKubeconfigManagerInjectable from "../../../main/kubeconfig-manager/create-kubeconfig-manager.injectable"; +import normalizedPlatformInjectable from "../../../common/vars/normalized-platform.injectable"; +import kubectlBinaryNameInjectable from "../../../main/kubectl/binary-name.injectable"; +import kubectlDownloadingNormalizedArchInjectable from "../../../main/kubectl/normalized-arch.injectable"; +import openDeleteClusterDialogInjectable, { type OpenDeleteClusterDialog } from "../../../renderer/components/delete-cluster-dialog/open.injectable"; +import { type ApplicationBuilder, getApplicationBuilder } from "../../../renderer/components/test-utils/get-application-builder"; +import storesAndApisCanBeCreatedInjectable from "../../../renderer/stores-apis-can-be-created.injectable"; +import type { Cluster } from "../../../common/cluster/cluster"; +import navigateToCatalogInjectable from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable"; +import directoryForKubeConfigsInjectable from "../../../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable"; +import joinPathsInjectable from "../../../common/path/join-paths.injectable"; + +const currentClusterServerUrl = "https://localhost"; +const nonCurrentClusterServerUrl = "http://localhost"; +const multiClusterConfig = ` +apiVersion: v1 +clusters: +- cluster: + server: ${currentClusterServerUrl} + name: some-current-context-cluster +- cluster: + server: ${nonCurrentClusterServerUrl} + name: some-non-current-context-cluster +contexts: +- context: + cluster: some-current-context-cluster + user: some-user + name: some-current-context +- context: + cluster: some-non-current-context-cluster + user: some-user + name: some-non-current-context +current-context: some-current-context +kind: Config +preferences: {} +users: +- name: some-user + user: + token: kubeconfig-user-q4lm4:xxxyyyy +`; + +const singleClusterServerUrl = "http://localhost"; +const singleClusterConfig = ` +apiVersion: v1 +clusters: +- cluster: + server: ${singleClusterServerUrl} + name: some-cluster +contexts: +- context: + cluster: some-cluster + user: some-user + name: some-context +current-context: some-context +kind: Config +preferences: {} +users: +- name: some-user + user: + token: kubeconfig-user-q4lm4:xxxyyyy +`; + +describe("Deleting a cluster", () => { + let builder: ApplicationBuilder; + let openDeleteClusterDialog: OpenDeleteClusterDialog; + let createCluster: CreateCluster; + let rendered: RenderResult; + let config: KubeConfig; + + beforeEach(async () => { + config = new KubeConfig(); + builder = getApplicationBuilder(); + + builder.beforeApplicationStart((mainDi) => { + mainDi.override(createContextHandlerInjectable, () => () => undefined as never); + mainDi.override(createKubeconfigManagerInjectable, () => () => undefined as never); + mainDi.override(kubectlBinaryNameInjectable, () => "kubectl"); + mainDi.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64"); + mainDi.override(normalizedPlatformInjectable, () => "darwin"); + }); + + builder.beforeWindowStart((windowDi) => { + windowDi.override(storesAndApisCanBeCreatedInjectable, () => true); + openDeleteClusterDialog = windowDi.inject(openDeleteClusterDialogInjectable); + }); + + builder.afterWindowStart(windowDi => { + createCluster = windowDi.inject(createClusterInjectionToken); + + const navigateToCatalog = windowDi.inject(navigateToCatalogInjectable); + + navigateToCatalog(); + }); + + rendered = await builder.render(); + }); + + describe("when the kubeconfig has multiple clusters", () => { + let currentCluster: Cluster; + let nonCurrentCluster: Cluster; + + beforeEach(() => { + config.loadFromString(multiClusterConfig); + + currentCluster = createCluster({ + id: "some-current-context-cluster", + contextName: "some-current-context", + preferences: { + clusterName: "some-current-context-cluster", + }, + kubeConfigPath: "./temp-kube-config", + }, { + clusterServerUrl: currentClusterServerUrl, + }); + nonCurrentCluster = createCluster({ + id: "some-non-current-context-cluster", + contextName: "some-non-current-context", + preferences: { + clusterName: "some-non-current-context-cluster", + }, + kubeConfigPath: "./temp-kube-config", + }, { + clusterServerUrl: currentClusterServerUrl, + }); + }); + + describe("when the dialog is opened for the current cluster", () => { + // TODO: replace with actual behaviour instead of technical use + beforeEach(async () => { + openDeleteClusterDialog({ + cluster: currentCluster, + config, + }); + + await rendered.findByTestId("delete-cluster-dialog"); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("shows context switcher", () => { + expect(rendered.queryByText("Select new context...")).toBeInTheDocument(); + }); + + it("shows warning", () => { + expect(rendered.queryByTestId("current-context-warning")).toBeInTheDocument(); + }); + }); + + describe("when the dialog is opened for not the current cluster", () => { + // TODO: replace with actual behaviour instead of technical use + beforeEach(async () => { + openDeleteClusterDialog({ + cluster: nonCurrentCluster, + config, + }); + + await rendered.findByTestId("delete-cluster-dialog"); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("shows warning", () => { + expect(rendered.queryByTestId("kubeconfig-change-warning")).toBeInTheDocument(); + }); + + it("does not show context switcher", () => { + expect(rendered.queryByText("Select new context...")).not.toBeInTheDocument(); + }); + + describe("when context switching checkbox is clicked", () => { + beforeEach(() => { + rendered.getByTestId("delete-cluster-dialog-context-switch").click(); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("shows context switcher", () => { + expect(rendered.queryByText("Select new context...")).toBeInTheDocument(); + }); + }); + }); + }); + + describe("when an internal kubeconfig cluster is used", () => { + let currentCluster: Cluster; + + beforeEach(() => { + config.loadFromString(singleClusterConfig); + + const directoryForKubeConfigs = builder.applicationWindow.only.di.inject(directoryForKubeConfigsInjectable); + const joinPaths = builder.applicationWindow.only.di.inject(joinPathsInjectable); + + currentCluster = createCluster({ + id: "some-cluster", + contextName: "some-context", + preferences: { + clusterName: "some-cluster", + }, + kubeConfigPath: joinPaths(directoryForKubeConfigs, "some-cluster.json"), + }, { + clusterServerUrl: singleClusterServerUrl, + }); + }); + + describe("when the dialog is opened", () => { + // TODO: replace with actual behaviour instead of technical use + beforeEach(async () => { + openDeleteClusterDialog({ + cluster: currentCluster, + config, + }); + + await rendered.findByTestId("delete-cluster-dialog"); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("shows warning", () => { + expect(rendered.queryByTestId("internal-kubeconfig-warning")).toBeInTheDocument(); + }); + }); + }); + + describe("when the kubeconfig has only one cluster", () => { + let currentCluster: Cluster; + + beforeEach(() => { + config.loadFromString(singleClusterConfig); + + currentCluster = createCluster({ + id: "some-cluster", + contextName: "some-context", + preferences: { + clusterName: "some-cluster", + }, + kubeConfigPath: "./temp-kube-config", + }, { + clusterServerUrl: singleClusterServerUrl, + }); + }); + + describe("when the dialog is opened", () => { + // TODO: replace with actual behaviour instead of technical use + beforeEach(async () => { + openDeleteClusterDialog({ + cluster: currentCluster, + config, + }); + + await rendered.findByTestId("delete-cluster-dialog"); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("shows warning", () => { + expect(rendered.queryByTestId("no-more-contexts-warning")).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/features/cluster/delete-dialog/main/clear-as-deleting-channel-listener.injectable.ts b/src/features/cluster/delete-dialog/main/clear-as-deleting-channel-listener.injectable.ts new file mode 100644 index 0000000000..a8dd2a80b0 --- /dev/null +++ b/src/features/cluster/delete-dialog/main/clear-as-deleting-channel-listener.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import clustersThatAreBeingDeletedInjectable from "../../../../main/cluster/are-being-deleted.injectable"; +import { getRequestChannelListenerInjectable } from "../../../../main/utils/channel/channel-listeners/listener-tokens"; +import { clearClusterAsDeletingChannel } from "../common/clear-as-deleting-channel"; + +const clearClusterAsDeletingChannelListenerInjectable = getRequestChannelListenerInjectable({ + channel: clearClusterAsDeletingChannel, + handler: (di) => { + const clustersThatAreBeingDeleted = di.inject(clustersThatAreBeingDeletedInjectable); + + return (clusterId) => { + clustersThatAreBeingDeleted.delete(clusterId); + }; + }, +}); + +export default clearClusterAsDeletingChannelListenerInjectable; diff --git a/src/features/cluster/delete-dialog/main/delete-channel-listener.injectable.ts b/src/features/cluster/delete-dialog/main/delete-channel-listener.injectable.ts new file mode 100644 index 0000000000..a2ede65ec0 --- /dev/null +++ b/src/features/cluster/delete-dialog/main/delete-channel-listener.injectable.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import emitAppEventInjectable from "../../../../common/app-event-bus/emit-event.injectable"; +import clusterFramesInjectable from "../../../../common/cluster-frames.injectable"; +import clusterStoreInjectable from "../../../../common/cluster-store/cluster-store.injectable"; +import directoryForLensLocalStorageInjectable from "../../../../common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable"; +import deleteFileInjectable from "../../../../common/fs/delete-file.injectable"; +import joinPathsInjectable from "../../../../common/path/join-paths.injectable"; +import { noop } from "../../../../common/utils"; +import { getRequestChannelListenerInjectable } from "../../../../main/utils/channel/channel-listeners/listener-tokens"; +import { deleteClusterChannel } from "../common/delete-channel"; + +const deleteClusterChannelListenerInjectable = getRequestChannelListenerInjectable({ + channel: deleteClusterChannel, + handler: (di) => { + const emitAppEvent = di.inject(emitAppEventInjectable); + const clusterStore = di.inject(clusterStoreInjectable); + const clusterFrames = di.inject(clusterFramesInjectable); + const joinPaths = di.inject(joinPathsInjectable); + const directoryForLensLocalStorage = di.inject(directoryForLensLocalStorageInjectable); + const deleteFile = di.inject(deleteFileInjectable); + + return async (clusterId) => { + emitAppEvent({ name: "cluster", action: "remove" }); + + const cluster = clusterStore.getById(clusterId); + + if (!cluster) { + return; + } + + cluster.disconnect(); + clusterFrames.delete(cluster.id); + + // Remove from the cluster store as well, this should clear any old settings + clusterStore.clusters.delete(cluster.id); + + // remove the local storage file + const localStorageFilePath = joinPaths(directoryForLensLocalStorage, `${cluster.id}.json`); + + await deleteFile(localStorageFilePath).catch(noop); + }; + }, +}); + +export default deleteClusterChannelListenerInjectable; diff --git a/src/features/cluster/delete-dialog/main/set-as-deleteing-channel-listener.injectable.ts b/src/features/cluster/delete-dialog/main/set-as-deleteing-channel-listener.injectable.ts new file mode 100644 index 0000000000..f532b4a81f --- /dev/null +++ b/src/features/cluster/delete-dialog/main/set-as-deleteing-channel-listener.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import clustersThatAreBeingDeletedInjectable from "../../../../main/cluster/are-being-deleted.injectable"; +import { getRequestChannelListenerInjectable } from "../../../../main/utils/channel/channel-listeners/listener-tokens"; +import { setClusterAsDeletingChannel } from "../common/set-as-deleting-channel"; + +const setClusterAsDeletingChannelHandlerInjectable = getRequestChannelListenerInjectable({ + channel: setClusterAsDeletingChannel, + handler: (di) => { + const clustersThatAreBeingDeleted = di.inject(clustersThatAreBeingDeletedInjectable); + + return (clusterId) => { + clustersThatAreBeingDeleted.add(clusterId); + }; + }, +}); + +export default setClusterAsDeletingChannelHandlerInjectable; diff --git a/src/features/cluster/delete-dialog/renderer/request-clear-as-deleting.injectable.ts b/src/features/cluster/delete-dialog/renderer/request-clear-as-deleting.injectable.ts new file mode 100644 index 0000000000..e476998ca0 --- /dev/null +++ b/src/features/cluster/delete-dialog/renderer/request-clear-as-deleting.injectable.ts @@ -0,0 +1,21 @@ +/** + * 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 type { ClusterId } from "../../../../common/cluster-types"; +import requestFromChannelInjectable from "../../../../renderer/utils/channel/request-from-channel.injectable"; +import { clearClusterAsDeletingChannel } from "../common/clear-as-deleting-channel"; + +export type RequestClearClusterAsDeleting = (clusterId: ClusterId) => Promise; + +const requestClearClusterAsDeletingInjectable = getInjectable({ + id: "request-clear-cluster-as-deleting", + instantiate: (di): RequestClearClusterAsDeleting => { + const requestChannel = di.inject(requestFromChannelInjectable); + + return (clusterId) => requestChannel(clearClusterAsDeletingChannel, clusterId); + }, +}); + +export default requestClearClusterAsDeletingInjectable; diff --git a/src/features/cluster/delete-dialog/renderer/request-delete.injectable.ts b/src/features/cluster/delete-dialog/renderer/request-delete.injectable.ts new file mode 100644 index 0000000000..c1286e3103 --- /dev/null +++ b/src/features/cluster/delete-dialog/renderer/request-delete.injectable.ts @@ -0,0 +1,21 @@ +/** + * 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 type { ClusterId } from "../../../../common/cluster-types"; +import requestFromChannelInjectable from "../../../../renderer/utils/channel/request-from-channel.injectable"; +import { deleteClusterChannel } from "../common/delete-channel"; + +export type RequestDeleteCluster = (clusterId: ClusterId) => Promise; + +const requestDeleteClusterInjectable = getInjectable({ + id: "request-delete-cluster", + instantiate: (di): RequestDeleteCluster => { + const requestChannel = di.inject(requestFromChannelInjectable); + + return (clusterId) => requestChannel(deleteClusterChannel, clusterId); + }, +}); + +export default requestDeleteClusterInjectable; diff --git a/src/features/cluster/delete-dialog/renderer/request-set-as-deleting.injectable.ts b/src/features/cluster/delete-dialog/renderer/request-set-as-deleting.injectable.ts new file mode 100644 index 0000000000..de3a6393b3 --- /dev/null +++ b/src/features/cluster/delete-dialog/renderer/request-set-as-deleting.injectable.ts @@ -0,0 +1,21 @@ +/** + * 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 type { ClusterId } from "../../../../common/cluster-types"; +import requestFromChannelInjectable from "../../../../renderer/utils/channel/request-from-channel.injectable"; +import { setClusterAsDeletingChannel } from "../common/set-as-deleting-channel"; + +export type RequestSetClusterAsDeleting = (clusterId: ClusterId) => Promise; + +const requestSetClusterAsDeletingInjectable = getInjectable({ + id: "request-set-cluster-as-deleting", + instantiate: (di): RequestSetClusterAsDeleting => { + const requestChannel = di.inject(requestFromChannelInjectable); + + return (clusterId) => requestChannel(setClusterAsDeletingChannel, clusterId); + }, +}); + +export default requestSetClusterAsDeletingInjectable; diff --git a/src/features/cluster/extension-api/__snapshots__/disable-cluster-pages-when-cluster-is-not-relevant.test.tsx.snap b/src/features/cluster/extension-api/__snapshots__/disable-cluster-pages-when-cluster-is-not-relevant.test.tsx.snap index d7daa6bf14..7c81e1dfd7 100644 --- a/src/features/cluster/extension-api/__snapshots__/disable-cluster-pages-when-cluster-is-not-relevant.test.tsx.snap +++ b/src/features/cluster/extension-api/__snapshots__/disable-cluster-pages-when-cluster-is-not-relevant.test.tsx.snap @@ -310,6 +310,7 @@ exports[`disable-cluster-pages-when-cluster-is-not-relevant given extension shou > -
+
Close ⌘+W
@@ -817,6 +820,7 @@ exports[`disable-cluster-pages-when-cluster-is-not-relevant given extension shou > -
+
Close ⌘+W
@@ -1324,6 +1330,7 @@ exports[`disable-cluster-pages-when-cluster-is-not-relevant given not yet known > -
+
Close ⌘+W
diff --git a/src/features/cluster/extension-api/__snapshots__/disable-sidebar-items-when-cluster-is-not-relevant.test.tsx.snap b/src/features/cluster/extension-api/__snapshots__/disable-sidebar-items-when-cluster-is-not-relevant.test.tsx.snap index 3dde5af5d5..5642dbcf22 100644 --- a/src/features/cluster/extension-api/__snapshots__/disable-sidebar-items-when-cluster-is-not-relevant.test.tsx.snap +++ b/src/features/cluster/extension-api/__snapshots__/disable-sidebar-items-when-cluster-is-not-relevant.test.tsx.snap @@ -448,6 +448,7 @@ exports[`disable sidebar items when cluster is not relevant given extension shou > -
+
Close ⌘+W
@@ -955,6 +958,7 @@ exports[`disable sidebar items when cluster is not relevant given extension shou > -
+
Close ⌘+W
@@ -1462,6 +1468,7 @@ exports[`disable sidebar items when cluster is not relevant given not yet known > -
+
Close ⌘+W
diff --git a/src/features/cluster/kube-object-details/extension-api/__snapshots__/disable-kube-object-detail-items-when-cluster-is-not-relevant.test.tsx.snap b/src/features/cluster/kube-object-details/extension-api/__snapshots__/disable-kube-object-detail-items-when-cluster-is-not-relevant.test.tsx.snap index 60f0d0095a..0221082e02 100644 --- a/src/features/cluster/kube-object-details/extension-api/__snapshots__/disable-kube-object-detail-items-when-cluster-is-not-relevant.test.tsx.snap +++ b/src/features/cluster/kube-object-details/extension-api/__snapshots__/disable-kube-object-detail-items-when-cluster-is-not-relevant.test.tsx.snap @@ -367,6 +367,7 @@ exports[`disable kube object detail items when cluster is not relevant given ext > -
+
Close ⌘+W
@@ -849,6 +852,7 @@ exports[`disable kube object detail items when cluster is not relevant given ext > -
+
Close ⌘+W
@@ -1331,6 +1337,7 @@ exports[`disable kube object detail items when cluster is not relevant given not > -
+
Close ⌘+W
diff --git a/src/features/cluster/kube-object-menu/extension-api/__snapshots__/disable-kube-object-menu-items-when-cluster-is-not-relevant.test.tsx.snap b/src/features/cluster/kube-object-menu/extension-api/__snapshots__/disable-kube-object-menu-items-when-cluster-is-not-relevant.test.tsx.snap index 9459045e02..292a820f4b 100644 --- a/src/features/cluster/kube-object-menu/extension-api/__snapshots__/disable-kube-object-menu-items-when-cluster-is-not-relevant.test.tsx.snap +++ b/src/features/cluster/kube-object-menu/extension-api/__snapshots__/disable-kube-object-menu-items-when-cluster-is-not-relevant.test.tsx.snap @@ -315,6 +315,7 @@ exports[`disable kube object menu items when cluster is not relevant given exten > -
+
Close ⌘+W
@@ -701,6 +704,7 @@ exports[`disable kube object menu items when cluster is not relevant given exten > -
+
Close ⌘+W
@@ -1087,6 +1093,7 @@ exports[`disable kube object menu items when cluster is not relevant given not y > -
+
Close ⌘+W
diff --git a/src/features/cluster/kube-object-status-icon/__snapshots__/show-status-for-a-kube-object.test.tsx.snap b/src/features/cluster/kube-object-status-icon/__snapshots__/show-status-for-a-kube-object.test.tsx.snap index 042e9f02ab..dec48cdc12 100644 --- a/src/features/cluster/kube-object-status-icon/__snapshots__/show-status-for-a-kube-object.test.tsx.snap +++ b/src/features/cluster/kube-object-status-icon/__snapshots__/show-status-for-a-kube-object.test.tsx.snap @@ -304,6 +304,7 @@ exports[`show status for a kube object given application starts and in test page > -
+
Close ⌘+W
@@ -725,6 +728,7 @@ exports[`show status for a kube object given application starts and in test page > -
+
Close ⌘+W
@@ -1146,6 +1152,7 @@ exports[`show status for a kube object given application starts and in test page > -
+
Close ⌘+W
@@ -1527,6 +1536,7 @@ exports[`show status for a kube object given application starts and in test page > -
+
Close ⌘+W
@@ -1908,6 +1920,7 @@ exports[`show status for a kube object given application starts and in test page > -
+
Close ⌘+W
@@ -2329,6 +2344,7 @@ exports[`show status for a kube object given application starts and in test page > -
+
Close ⌘+W
diff --git a/src/features/cluster/kube-object-status-icon/extension-api/__snapshots__/disable-kube-object-statuses-when-cluster-is-not-relevant.test.tsx.snap b/src/features/cluster/kube-object-status-icon/extension-api/__snapshots__/disable-kube-object-statuses-when-cluster-is-not-relevant.test.tsx.snap index 001f97e00b..9fc7e6bbf1 100644 --- a/src/features/cluster/kube-object-status-icon/extension-api/__snapshots__/disable-kube-object-statuses-when-cluster-is-not-relevant.test.tsx.snap +++ b/src/features/cluster/kube-object-status-icon/extension-api/__snapshots__/disable-kube-object-statuses-when-cluster-is-not-relevant.test.tsx.snap @@ -344,6 +344,7 @@ exports[`disable kube object statuses when cluster is not relevant given extensi > -
+
Close ⌘+W
@@ -725,6 +728,7 @@ exports[`disable kube object statuses when cluster is not relevant given extensi > -
+
Close ⌘+W
@@ -1106,6 +1112,7 @@ exports[`disable kube object statuses when cluster is not relevant given not yet > -
+
Close ⌘+W
diff --git a/src/features/cluster/legacy-extension-adding-cluster-frame-components.test.tsx b/src/features/cluster/legacy-extension-adding-cluster-frame-components.test.tsx new file mode 100644 index 0000000000..f837550f7a --- /dev/null +++ b/src/features/cluster/legacy-extension-adding-cluster-frame-components.test.tsx @@ -0,0 +1,78 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { RenderResult } from "@testing-library/react"; +import { act } from "@testing-library/react"; +import type { IObservableValue } from "mobx"; +import { computed, observable, runInAction } from "mobx"; +import React from "react"; +import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; + +describe("legacy extension adding cluster frame components", () => { + let builder: ApplicationBuilder; + let rendered: RenderResult; + + beforeEach(() => { + builder = getApplicationBuilder(); + builder.setEnvironmentToClusterFrame(); + }); + + describe("given custom components for cluster view available", () => { + let someObservable: IObservableValue; + + beforeEach(async () => { + someObservable = observable.box(false); + + const testExtension = { + id: "some-extension-id", + name: "some-extension-name", + + rendererOptions: { + clusterFrameComponents: [ + { + id: "test-modal-id", + Component: () =>
test modal
, + shouldRender: computed(() => true), + }, + { + id: "dialog-with-observable-visibility-id", + Component: () =>
dialog contents
, + shouldRender: computed(() => someObservable.get()), + }, + ], + }, + }; + + rendered = await builder.render(); + builder.extensions.enable(testExtension); + }); + + it("renders", () => { + expect(rendered.container).toMatchSnapshot(); + }); + + it("renders provided component html", () => { + const modal = rendered.getByTestId("test-modal"); + + expect(modal).toBeInTheDocument(); + }); + + it("doesn't render component which should be invisible", () => { + const dialog = rendered.queryByTestId("dialog-with-observable-visibility"); + + expect(dialog).not.toBeInTheDocument(); + }); + + it("when injectable component becomes visible, shows it", () => { + runInAction(() => { + act(() => someObservable.set(true)); + }); + + const dialog = rendered.getByTestId("dialog-with-observable-visibility"); + + expect(dialog).toBeInTheDocument(); + }); + }); +}); diff --git a/src/features/cluster/namespaces/__snapshots__/edit-namespace-from-new-tab.test.tsx.snap b/src/features/cluster/namespaces/__snapshots__/edit-namespace-from-new-tab.test.tsx.snap index f46517dc0b..b087beee3d 100644 --- a/src/features/cluster/namespaces/__snapshots__/edit-namespace-from-new-tab.test.tsx.snap +++ b/src/features/cluster/namespaces/__snapshots__/edit-namespace-from-new-tab.test.tsx.snap @@ -548,6 +548,7 @@ exports[`cluster/namespaces - edit namespace from new tab when navigating to nam > -
+
Close ⌘+W
@@ -1183,6 +1186,7 @@ exports[`cluster/namespaces - edit namespace from new tab when navigating to nam > -
+
Close ⌘+W
@@ -1823,6 +1829,7 @@ exports[`cluster/namespaces - edit namespace from new tab when navigating to nam > -
+
Close ⌘+W
@@ -2532,6 +2541,7 @@ exports[`cluster/namespaces - edit namespace from new tab when navigating to nam > -
+
Close ⌘+W
@@ -2581,6 +2593,7 @@ exports[`cluster/namespaces - edit namespace from new tab when navigating to nam > -
+
Close ⌘+W
@@ -3216,6 +3231,7 @@ exports[`cluster/namespaces - edit namespace from new tab when navigating to nam > -
+
Close ⌘+W
@@ -3265,6 +3283,7 @@ exports[`cluster/namespaces - edit namespace from new tab when navigating to nam > -
+
Close ⌘+W
@@ -3972,6 +3993,7 @@ exports[`cluster/namespaces - edit namespace from new tab when navigating to nam > -
+
Close ⌘+W
@@ -4021,6 +4045,7 @@ exports[`cluster/namespaces - edit namespace from new tab when navigating to nam > -
+
Close ⌘+W
@@ -4730,6 +4757,7 @@ exports[`cluster/namespaces - edit namespace from new tab when navigating to nam > -
+
Close ⌘+W
@@ -5453,6 +5483,7 @@ exports[`cluster/namespaces - edit namespace from new tab when navigating to nam > -
+
Close ⌘+W
@@ -6171,6 +6204,7 @@ exports[`cluster/namespaces - edit namespace from new tab when navigating to nam > -
+
Close ⌘+W
@@ -6880,6 +6916,7 @@ exports[`cluster/namespaces - edit namespace from new tab when navigating to nam > -
+
Close ⌘+W
@@ -7589,6 +7628,7 @@ exports[`cluster/namespaces - edit namespace from new tab when navigating to nam > -
+
Close ⌘+W
@@ -8845,6 +8887,7 @@ exports[`cluster/namespaces - edit namespace from new tab when navigating to nam > -
+
Close ⌘+W
@@ -10657,6 +10702,7 @@ exports[`cluster/namespaces - edit namespace from new tab when navigating to nam > -
+
Close ⌘+W
diff --git a/src/features/cluster/namespaces/__snapshots__/edit-namespace-from-previously-opened-tab.test.tsx.snap b/src/features/cluster/namespaces/__snapshots__/edit-namespace-from-previously-opened-tab.test.tsx.snap index e23184d8fc..f82cbc2e0e 100644 --- a/src/features/cluster/namespaces/__snapshots__/edit-namespace-from-previously-opened-tab.test.tsx.snap +++ b/src/features/cluster/namespaces/__snapshots__/edit-namespace-from-previously-opened-tab.test.tsx.snap @@ -455,6 +455,7 @@ exports[`cluster/namespaces - edit namespaces from previously opened tab given t > -
+
Close ⌘+W
@@ -997,6 +1000,7 @@ exports[`cluster/namespaces - edit namespaces from previously opened tab given t > -
+
Close ⌘+W
diff --git a/src/features/cluster/sidebar-and-tab-navigation-for-core.test.tsx b/src/features/cluster/sidebar-and-tab-navigation-for-core.test.tsx index 2272fc0db6..519e6bb3c8 100644 --- a/src/features/cluster/sidebar-and-tab-navigation-for-core.test.tsx +++ b/src/features/cluster/sidebar-and-tab-navigation-for-core.test.tsx @@ -22,7 +22,6 @@ import pathExistsInjectable from "../../common/fs/path-exists.injectable"; import readJsonFileInjectable from "../../common/fs/read-json-file.injectable"; import { navigateToRouteInjectionToken } from "../../common/front-end-routing/navigate-to-route-injection-token"; import sidebarStorageInjectable from "../../renderer/components/layout/sidebar-storage/sidebar-storage.injectable"; -import hostedClusterIdInjectable from "../../renderer/cluster-frame-context/hosted-cluster-id.injectable"; import { advanceFakeTime, useFakeTime } from "../../common/test-utils/use-fake-time"; import storageSaveDelayInjectable from "../../renderer/utils/create-storage/storage-save-delay.injectable"; @@ -38,7 +37,6 @@ describe("cluster - sidebar and tab navigation for core", () => { builder.setEnvironmentToClusterFrame(); builder.beforeWindowStart((windowDi) => { - windowDi.override(hostedClusterIdInjectable, () => "some-hosted-cluster-id"); windowDi.override(storageSaveDelayInjectable, () => 250); windowDi.override( @@ -98,7 +96,7 @@ describe("cluster - sidebar and tab navigation for core", () => { const writeJsonFileFake = windowDi.inject(writeJsonFileInjectable); await writeJsonFileFake( - "/some-directory-for-lens-local-storage/some-hosted-cluster-id.json", + "/some-directory-for-lens-local-storage/some-cluster-id.json", { sidebar: { expanded: { "some-parent-id": true }, @@ -286,7 +284,7 @@ describe("cluster - sidebar and tab navigation for core", () => { const readJsonFileFake = windowDi.inject(readJsonFileInjectable); const actual = await readJsonFileFake( - "/some-directory-for-lens-local-storage/some-hosted-cluster-id.json", + "/some-directory-for-lens-local-storage/some-cluster-id.json", ); expect(actual).toEqual({ diff --git a/src/features/cluster/sidebar-and-tab-navigation-for-extensions.test.tsx b/src/features/cluster/sidebar-and-tab-navigation-for-extensions.test.tsx index 8d3de582ad..a228cc880e 100644 --- a/src/features/cluster/sidebar-and-tab-navigation-for-extensions.test.tsx +++ b/src/features/cluster/sidebar-and-tab-navigation-for-extensions.test.tsx @@ -15,7 +15,6 @@ import pathExistsInjectable from "../../common/fs/path-exists.injectable"; import readJsonFileInjectable from "../../common/fs/read-json-file.injectable"; import { navigateToRouteInjectionToken } from "../../common/front-end-routing/navigate-to-route-injection-token"; import assert from "assert"; -import hostedClusterIdInjectable from "../../renderer/cluster-frame-context/hosted-cluster-id.injectable"; import { advanceFakeTime, useFakeTime } from "../../common/test-utils/use-fake-time"; import type { IObservableValue } from "mobx"; import { runInAction, computed, observable } from "mobx"; @@ -34,7 +33,6 @@ describe("cluster - sidebar and tab navigation for extensions", () => { applicationBuilder.setEnvironmentToClusterFrame(); applicationBuilder.beforeWindowStart((windowDi) => { - windowDi.override(hostedClusterIdInjectable, () => "some-hosted-cluster-id"); windowDi.override(storageSaveDelayInjectable, () => 250); windowDi.override( @@ -178,7 +176,7 @@ describe("cluster - sidebar and tab navigation for extensions", () => { const writeJsonFileFake = windowDi.inject(writeJsonFileInjectable); await writeJsonFileFake( - "/some-directory-for-lens-local-storage/some-hosted-cluster-id.json", + "/some-directory-for-lens-local-storage/some-cluster-id.json", { sidebar: { expanded: { "some-extension-name-some-parent-id": true }, @@ -214,7 +212,7 @@ describe("cluster - sidebar and tab navigation for extensions", () => { const writeJsonFileFake = windowDi.inject(writeJsonFileInjectable); await writeJsonFileFake( - "/some-directory-for-lens-local-storage/some-hosted-cluster-id.json", + "/some-directory-for-lens-local-storage/some-cluster-id.json", { sidebar: { expanded: { "some-extension-name-some-unknown-parent-id": true }, @@ -244,7 +242,7 @@ describe("cluster - sidebar and tab navigation for extensions", () => { const writeJsonFileFake = windowDi.inject(writeJsonFileInjectable); await writeJsonFileFake( - "/some-directory-for-lens-local-storage/some-hosted-cluster-id.json", + "/some-directory-for-lens-local-storage/some-cluster-id.json", { someThingButSidebar: {}, }, @@ -390,7 +388,7 @@ describe("cluster - sidebar and tab navigation for extensions", () => { const pathExistsFake = windowDi.inject(pathExistsInjectable); const actual = await pathExistsFake( - "/some-directory-for-lens-local-storage/some-hosted-cluster-id.json", + "/some-directory-for-lens-local-storage/some-cluster-id.json", ); expect(actual).toBe(false); @@ -402,7 +400,7 @@ describe("cluster - sidebar and tab navigation for extensions", () => { const readJsonFileFake = windowDi.inject(readJsonFileInjectable); const actual = await readJsonFileFake( - "/some-directory-for-lens-local-storage/some-hosted-cluster-id.json", + "/some-directory-for-lens-local-storage/some-cluster-id.json", ); expect(actual).toEqual({ diff --git a/src/features/cluster/workloads/overview/extension-api/__snapshots__/disable-workloads-overview-details-when-cluster-is-not-relevant.test.tsx.snap b/src/features/cluster/workloads/overview/extension-api/__snapshots__/disable-workloads-overview-details-when-cluster-is-not-relevant.test.tsx.snap index 2fd23c30cc..4dc26ef987 100644 --- a/src/features/cluster/workloads/overview/extension-api/__snapshots__/disable-workloads-overview-details-when-cluster-is-not-relevant.test.tsx.snap +++ b/src/features/cluster/workloads/overview/extension-api/__snapshots__/disable-workloads-overview-details-when-cluster-is-not-relevant.test.tsx.snap @@ -435,6 +435,7 @@ exports[`disable workloads overview details when cluster is not relevant given e > -
+
Close ⌘+W
@@ -942,6 +945,7 @@ exports[`disable workloads overview details when cluster is not relevant given e > -
+
Close ⌘+W
@@ -1449,6 +1455,7 @@ exports[`disable workloads overview details when cluster is not relevant given n > -
+
Close ⌘+W
diff --git a/src/features/command-pallet/__snapshots__/keyboard-shortcuts.test.tsx.snap b/src/features/command-pallet/__snapshots__/keyboard-shortcuts.test.tsx.snap index cf68d3d742..2217f50a3d 100644 --- a/src/features/command-pallet/__snapshots__/keyboard-shortcuts.test.tsx.snap +++ b/src/features/command-pallet/__snapshots__/keyboard-shortcuts.test.tsx.snap @@ -13,121 +13,148 @@ exports[`Command Pallet: keyboard shortcut tests when on linux renders 1`] = ` class="items" >
- - - - - + + + + + +
- - - home - - - - - arrow_back - - - - - arrow_forward - - -
-
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
- - - -
-
- + + +
+
- - -
-
- + + +
+
- - + + + +
@@ -159,7 +186,7 @@ exports[`Command Pallet: keyboard shortcut tests when on linux renders 1`] = ` style="width: 320px;" >

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -265,6 +292,7 @@ exports[`Command Pallet: keyboard shortcut tests when on linux renders 1`] = `

- - - - - + + + + + +
- - - home - - - - - arrow_back - - - - - arrow_forward - - -
-
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
- - - -
-
- + + +
+
- - -
-
- + + +
+
- - + + + +
@@ -442,7 +497,7 @@ exports[`Command Pallet: keyboard shortcut tests when on linux when pressing ESC style="width: 320px;" >

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -548,6 +603,7 @@ exports[`Command Pallet: keyboard shortcut tests when on linux when pressing ESC

- - - - - + + + + + +
- - - home - - - - - arrow_back - - - - - arrow_forward - - -
-
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
- - - -
-
- + + +
+
- - -
-
- + + +
+
- - + + + +
@@ -725,7 +808,7 @@ exports[`Command Pallet: keyboard shortcut tests when on linux when pressing SHI style="width: 320px;" >

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -831,6 +914,7 @@ exports[`Command Pallet: keyboard shortcut tests when on linux when pressing SHI

- - - - - + + + + + +
- - - home - - - - - arrow_back - - - - - arrow_forward - - -
-
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
- - - -
-
- + + +
+
- - -
-
- + + +
+
- - + + + +
@@ -1020,7 +1131,7 @@ exports[`Command Pallet: keyboard shortcut tests when on linux when pressing SHI style="width: 320px;" >

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -1126,6 +1237,7 @@ exports[`Command Pallet: keyboard shortcut tests when on linux when pressing SHI

- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -1328,6 +1457,7 @@ exports[`Command Pallet: keyboard shortcut tests when on macOS renders 1`] = `

- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -1530,6 +1677,7 @@ exports[`Command Pallet: keyboard shortcut tests when on macOS when pressing ESC

- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -1732,6 +1897,7 @@ exports[`Command Pallet: keyboard shortcut tests when on macOS when pressing SHI

- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -1946,6 +2129,7 @@ exports[`Command Pallet: keyboard shortcut tests when on macOS when pressing SHI

- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-

- Welcome to OpenLens! + Welcome to some-product-name!

To get you started we have auto-detected your clusters in your @@ -183,6 +200,7 @@ exports[`extensions - navigation using application menu renders 1`] = `

- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-
{ let builder: ApplicationBuilder; let rendered: RenderResult; let focusWindowMock: jest.Mock; + let downloadJson: jest.MockedFunction; + let downloadBinary: jest.MockedFunction; beforeEach(async () => { builder = getApplicationBuilder(); builder.beforeWindowStart((windowDi) => { focusWindowMock = jest.fn(); + downloadJson = jest.fn().mockImplementation((url) => { throw new Error(`Unexpected call to downloadJson for url=${url}`); }); + downloadBinary = jest.fn().mockImplementation((url) => { throw new Error(`Unexpected call to downloadJson for url=${url}`); }); windowDi.override(focusWindowInjectable, () => focusWindowMock); + windowDi.override(downloadJsonInjectable, () => downloadJson); + windowDi.override(downloadBinaryInjectable, () => downloadBinary); }); rendered = await builder.render(); @@ -40,7 +48,7 @@ describe("extensions - navigation using application menu", () => { describe("when navigating to extensions using application menu", () => { beforeEach(() => { - builder.applicationMenu.click("root.extensions"); + builder.applicationMenu.click("root", "mac", "navigate-to-extensions"); }); it("focuses the window", () => { diff --git a/src/features/helm-charts/__snapshots__/add-custom-helm-repository-in-preferences.test.ts.snap b/src/features/helm-charts/__snapshots__/add-custom-helm-repository-in-preferences.test.ts.snap index c02c727480..5f2785c71f 100644 --- a/src/features/helm-charts/__snapshots__/add-custom-helm-repository-in-preferences.test.ts.snap +++ b/src/features/helm-charts/__snapshots__/add-custom-helm-repository-in-preferences.test.ts.snap @@ -12,44 +12,62 @@ exports[`add custom helm repository in preferences when navigating to preference
- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-
@@ -144,231 +165,61 @@ exports[`add custom helm repository in preferences when navigating to preference class="content" >
-
-

+
+
- Kubernetes -

-
- Kubectl binary download - -
- -
-
-
- Download mirror - -
-
- - -
+
-
- Download mirror for kubectl -
-
- -
+ Kubectl binary download +
-
- - -
-
-
-
-
-
- Directory for binaries - + +
- -
-
+ class="size-xl" + />
- The directory to download binaries into. -
- -
-
- Path to kubectl binary - -
-
- -
-
-
- -
-
-

- Kubeconfig Syncs -

-
- -
-
- Synced Items - -
-
-
- No files and folders have been synced yet -
-
-
-
-
-

- Helm Charts -

-
-
-
+
+ Download mirror + +
+
- Repositories + Download mirror for kubectl
- @@ -452,30 +289,256 @@ exports[`add custom helm repository in preferences when navigating to preference
- -
-
+
+
+
+
+
+ Directory for binaries + +
+
+
-
-
+
+ The directory to download binaries into. +
+
-
- +
+
+
+
+ Path to kubectl binary + +
+
+ +
+
+
+
+ +
+
+
+
+

+ Kubeconfig Syncs +

+
+ +
+
+ Synced Items + +
+
+
+ No files and folders have been synced yet +
+
+
+
+
+
+
+

+ Helm Charts +

+
+
+
+
+ + +
+
+
+ Repositories +
+
+ +
+
+
+ + + +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-
@@ -722,231 +807,61 @@ exports[`add custom helm repository in preferences when navigating to preference class="content" >
-
-

+
+
- Kubernetes -

-
- Kubectl binary download - -
- -
-
-
- Download mirror - -
-
- - -
+
-
- Download mirror for kubectl -
-
- -
+ Kubectl binary download +
-
- - -
-
-
-
-
-
- Directory for binaries - + +
- -
-
+ class="size-xl" + />
- The directory to download binaries into. -
- -
-
- Path to kubectl binary - -
-
- -
-
-
- -
-
-

- Kubeconfig Syncs -

-
- -
-
- Synced Items - -
-
-
- No files and folders have been synced yet -
-
-
-
-
-

- Helm Charts -

-
-
-
+
+ Download mirror + +
+
- Repositories + Download mirror for kubectl
-
+
+
+
+
- Add Custom Helm Repo - -
+ Directory for binaries + +
+
+ +
+
+
+ The directory to download binaries into. +
+
+
+
+
+
+
+ Path to kubectl binary + +
+
+ +
+
+
+
+
+
+
+
+
+

+ Kubeconfig Syncs +

+
+ +
+
+ Synced Items + +
+
+ No files and folders have been synced yet +
+
+
+
+
+
+
+

+ Helm Charts +

+
+
-
-
- Some active repository -
-
- some-url -
-
- + +
- delete - - -
+
+ Repositories +
+
+ +
+
+
+ + +
+
+
+ +
+
+
+
+
+ Some active repository +
+
+ some-url +
+
+ + + delete + + +
+ Remove +
+
-
-
-
+ +
- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-
@@ -1311,231 +1460,61 @@ exports[`add custom helm repository in preferences when navigating to preference class="content" >
-
-

+
+
- Kubernetes -

-
- Kubectl binary download - -
- -
-
-
- Download mirror - -
-
- - -
+
-
- Download mirror for kubectl -
-
- -
+ Kubectl binary download +
-
- - -
-
-
-
-
-
- Directory for binaries - + +
- -
-
+ class="size-xl" + />
- The directory to download binaries into. -
- -
-
- Path to kubectl binary - -
-
- -
-
-
- -
-
-

- Kubeconfig Syncs -

-
- -
-
- Synced Items - -
-
-
- No files and folders have been synced yet -
-
-
-
-
-

- Helm Charts -

-
-
-
+
+ Download mirror + +
+
- Repositories + Download mirror for kubectl
-
+
+
+
+
- Add Custom Helm Repo - -
+ Directory for binaries + +
+
+ +
+
+
+ The directory to download binaries into. +
+
+
+
+
+
+
+ Path to kubectl binary + +
+
+ +
+
+
+
+
+
+
+
+
+

+ Kubeconfig Syncs +

+
+ +
+
+ Synced Items + +
+
+ No files and folders have been synced yet +
+
+
+
+
+
+
+

+ Helm Charts +

+
+
-
-
- Some active repository -
-
- some-url -
-
- + +
- delete - - -
+
+ Repositories +
+
+ +
+
+
+ + +
+
+
+ +
+
+
+
+
+ Some active repository +
+
+ some-url +
+
+ + + delete + + +
+ Remove +
+
-
-
-
+ +
- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-
@@ -2011,231 +2224,61 @@ exports[`add custom helm repository in preferences when navigating to preference class="content" >
-
-

+
+
- Kubernetes -

-
- Kubectl binary download - -
- -
-
-
- Download mirror - -
-
- - -
+
-
- Download mirror for kubectl -
-
- -
+ Kubectl binary download +
-
- - -
-
-
-
-
-
- Directory for binaries - + +
- -
-
+ class="size-xl" + />
- The directory to download binaries into. -
- -
-
- Path to kubectl binary - -
-
- -
-
-
- -
-
-

- Kubeconfig Syncs -

-
- -
-
- Synced Items - -
-
-
- No files and folders have been synced yet -
-
-
-
-
-

- Helm Charts -

-
-
-
+
+ Download mirror + +
+
- Repositories + Download mirror for kubectl
-
+
+
+
+
- Add Custom Helm Repo - -
+ Directory for binaries + +
+
+ +
+
+
+ The directory to download binaries into. +
+
+
+
+
+
+
+ Path to kubectl binary + +
+
+ +
+
+
+
+
+
+
+
+
+

+ Kubeconfig Syncs +

+
+ +
+
+ Synced Items + +
+
+ No files and folders have been synced yet +
+
+
+
+
+
+
+

+ Helm Charts +

+
+
-
-
- Some active repository -
-
- some-url -
-
- + +
- delete - - -
+
+ Repositories +
+
+ +
+
+
+ + +
+
+
+ +
+
+
+
+
+ Some active repository +
+
+ some-url +
+
+ + + delete + + +
+ Remove +
+
-
-
-
+ +
- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-
@@ -2608,231 +2885,61 @@ exports[`add custom helm repository in preferences when navigating to preference class="content" >
-
-

+
+
- Kubernetes -

-
- Kubectl binary download - -
- -
-
-
- Download mirror - -
-
- - -
+
-
- Download mirror for kubectl -
-
- -
+ Kubectl binary download +
-
- - -
-
-
-
-
-
- Directory for binaries - + +
- -
-
+ class="size-xl" + />
- The directory to download binaries into. -
- -
-
- Path to kubectl binary - -
-
- -
-
-
- -
-
-

- Kubeconfig Syncs -

-
- -
-
- Synced Items - -
-
-
- No files and folders have been synced yet -
-
-
-
-
-

- Helm Charts -

-
-
-
+
+ Download mirror + +
+
- Repositories + Download mirror for kubectl
-
+
+
+
+
- Add Custom Helm Repo - -
+ Directory for binaries + +
+
+ +
+
+
+ The directory to download binaries into. +
+
+
+
+
+
+
+ Path to kubectl binary + +
+
+ +
+
+
+
+
+
+
+
+
+

+ Kubeconfig Syncs +

+
+ +
+
+ Synced Items + +
+
+ No files and folders have been synced yet +
+
+
+
+
+
+
+

+ Helm Charts +

+
+
-
-
- Some active repository -
-
- some-url -
-
- + +
- delete - - -
+
+ Repositories +
+
+ +
+
+
+ + +
+
+
+ +
+
+
+
+
+ Some active repository +
+
+ some-url +
+
+ + + delete + + +
+ Remove +
+
-
-
-
+ +
- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-
@@ -3308,231 +3649,61 @@ exports[`add custom helm repository in preferences when navigating to preference class="content" >
-
-

+
+
- Kubernetes -

-
- Kubectl binary download - -
- -
-
-
- Download mirror - -
-
- - -
+
-
- Download mirror for kubectl -
-
- -
+ Kubectl binary download +
-
- - -
-
-
-
-
-
- Directory for binaries - + +
- -
-
+ class="size-xl" + />
- The directory to download binaries into. -
- -
-
- Path to kubectl binary - -
-
- -
-
-
- -
-
-

- Kubeconfig Syncs -

-
- -
-
- Synced Items - -
-
-
- No files and folders have been synced yet -
-
-
-
-
-

- Helm Charts -

-
-
-
+
+ Download mirror + +
+
- Repositories + Download mirror for kubectl
-
+
+
+
+
- Add Custom Helm Repo - -
+ Directory for binaries + +
+
+ +
+
+
+ The directory to download binaries into. +
+
+
+
+
+
+
+ Path to kubectl binary + +
+
+ +
+
+
+
+
+
+
+
+
+

+ Kubeconfig Syncs +

+
+ +
+
+ Synced Items + +
+
+ No files and folders have been synced yet +
+
+
+
+
+
+
+

+ Helm Charts +

+
+
-
-
- Some active repository -
-
- some-url -
-
- + +
- delete - - -
+
+ Repositories +
+
+ +
+
+
+ + +
+
+
+ +
+
+
+
+
+ Some active repository +
+
+ some-url +
+
+ + + delete + + +
+ Remove +
+
-
-
-
+ +
- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-
@@ -4190,231 +4595,61 @@ exports[`add custom helm repository in preferences when navigating to preference class="content" >
-
-

+
+
- Kubernetes -

-
- Kubectl binary download - -
- -
-
-
- Download mirror - -
-
- - -
+
-
- Download mirror for kubectl -
-
- -
+ Kubectl binary download +
-
- - -
-
-
-
-
-
- Directory for binaries - + +
- -
-
+ class="size-xl" + />
- The directory to download binaries into. -
- -
-
- Path to kubectl binary - -
-
- -
-
-
- -
-
-

- Kubeconfig Syncs -

-
- -
-
- Synced Items - -
-
-
- No files and folders have been synced yet -
-
-
-
-
-

- Helm Charts -

-
-
-
+
+ Download mirror + +
+
- Repositories + Download mirror for kubectl
-
+
+
+
+
- Add Custom Helm Repo - -
+ Directory for binaries + +
+
+ +
+
+
+ The directory to download binaries into. +
+
+
+
+
+
+
+ Path to kubectl binary + +
+
+ +
+
+
+
+
+
+
+
+
+

+ Kubeconfig Syncs +

+
+ +
+
+ Synced Items + +
+
+ No files and folders have been synced yet +
+
+
+
+
+
+
+

+ Helm Charts +

+
+
-
-
- Some active repository -
-
- some-url -
-
- + +
- delete - - -
+
+ Repositories +
+
+ +
+
+
+ + +
+
+
+ +
+
+
+
+
+ Some active repository +
+
+ some-url +
+
+ + + delete + + +
+ Remove +
+
-
-
-
+ +
- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-
@@ -4890,231 +5359,61 @@ exports[`add custom helm repository in preferences when navigating to preference class="content" >
-
-

+
+
- Kubernetes -

-
- Kubectl binary download - -
- -
-
-
- Download mirror - -
-
- - -
+
-
- Download mirror for kubectl -
-
- -
+ Kubectl binary download +
-
- - -
-
-
-
-
-
- Directory for binaries - + +
- -
-
+ class="size-xl" + />
- The directory to download binaries into. -
- -
-
- Path to kubectl binary - -
-
- -
-
-
- -
-
-

- Kubeconfig Syncs -

-
- -
-
- Synced Items - -
-
-
- No files and folders have been synced yet -
-
-
-
-
-

- Helm Charts -

-
-
-
+
+ Download mirror + +
+
- Repositories + Download mirror for kubectl
-
+
+
+
+
- Add Custom Helm Repo - -
+ Directory for binaries + +
+
+ +
+
+
+ The directory to download binaries into. +
+
+
+
+
+
+
+ Path to kubectl binary + +
+
+ +
+
+
+
+
+
+
+
+
+

+ Kubeconfig Syncs +

+
+ +
+
+ Synced Items + +
+
+ No files and folders have been synced yet +
+
+
+
+
+
+
+

+ Helm Charts +

+
+
-
-
- Some active repository -
-
- some-url -
-
- + +
- delete - - -
+
+ Repositories +
+
+ +
+
+
+ + +
+
+
+ +
+
+
+
+
+ Some active repository +
+
+ some-url +
+
+ + + delete + + +
+ Remove +
+
-
-
-
+ +
- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-
@@ -5772,231 +6305,61 @@ exports[`add custom helm repository in preferences when navigating to preference class="content" >
-
-

+
+
- Kubernetes -

-
- Kubectl binary download - -
- -
-
-
- Download mirror - -
-
- - -
+
-
- Download mirror for kubectl -
-
- -
+ Kubectl binary download +
-
- - -
-
-
-
-
-
- Directory for binaries - + +
- -
-
+ class="size-xl" + />
- The directory to download binaries into. -
- -
-
- Path to kubectl binary - -
-
- -
-
-
- -
-
-

- Kubeconfig Syncs -

-
- -
-
- Synced Items - -
-
-
- No files and folders have been synced yet -
-
-
-
-
-

- Helm Charts -

-
-
-
+
+ Download mirror + +
+
- Repositories + Download mirror for kubectl
-
+
+
+
+
- Add Custom Helm Repo - -
+ Directory for binaries + +
+
+ +
+
+
+ The directory to download binaries into. +
+
+
+
+
+
+
+ Path to kubectl binary + +
+
+ +
+
+
+
+
+
+
+
+
+

+ Kubeconfig Syncs +

+
+ +
+
+ Synced Items + +
+
+ No files and folders have been synced yet +
+
+
+
+
+
+
+

+ Helm Charts +

+
+
-
-
- Some active repository -
-
- some-url -
-
- + +
- delete - - -
+
+ Repositories +
+
+ +
+
+
+ + +
+
+
+ +
+
+
+
+
+ Some active repository +
+
+ some-url +
+
+ + + delete + + +
+ Remove +
+
-
-
-
+ +
- - - home - - - + home + + +
+
+
- - arrow_back - - - + arrow_back + + +
+
+
- - arrow_forward - - + + arrow_forward + + +
+
-
@@ -6473,231 +7070,61 @@ exports[`add custom helm repository in preferences when navigating to preference class="content" >
-
-

+
+
- Kubernetes -

-
- Kubectl binary download - -
- -
-
-
- Download mirror - -
-
- - -
+
-
- Download mirror for kubectl -
-
- -
+ Kubectl binary download +
-
- - -
-
-
-
-
-
- Directory for binaries - + +
- -
-
+ class="size-xl" + />
- The directory to download binaries into. -
- -
-
- Path to kubectl binary - -
-
- -
-
-
- -
-
-

- Kubeconfig Syncs -

-
- -
-
- Synced Items - -
-
-
- No files and folders have been synced yet -
-
-
-
-
-

- Helm Charts -

-
-
-
+
+ Download mirror + +
+
- Repositories + Download mirror for kubectl
-
+
+
+
+
- Add Custom Helm Repo - -
+ Directory for binaries + +
+
+ +
+
+
+ The directory to download binaries into. +
+
+
+
+
+
+
+ Path to kubectl binary + +
+
+ +
+
+
+
+
+
+
+
+
+

+ Kubeconfig Syncs +

+
+ +
+
+ Synced Items + +
+
+ No files and folders have been synced yet +
+
+
+
+
+
+
+

+ Helm Charts +

+
+
-
-
- Some active repository -
-
- some-url -
-
- + +
- delete - - -
+
+ Repositories +
+
+ +
+
+
+ + +
+
+
+ +
+
+
+
+
+ Some active repository +
+
+ some-url +
+
+ + + delete + + +
+ Remove +
+
-
-
-
+ +